mycode/myApp/HaruDanim/IOS/ViewModels/GoalViewModel.swift

172 lines
6.6 KiB
Swift

//
// GoalViewModel.swift
// HaruDanim
//
// Computes how much progress a `Quest` has accumulated within its current
// period, and evaluates whether it is being met given its direction.
//
import Foundation
import SwiftData
import Observation
/// The evaluated outcome of a quest for its current period.
enum QuestEvaluation {
/// `above` quest whose target has been reached.
case achieved
/// `above` quest still short of its target (period ongoing).
case inProgress
/// `below` quest still within its allowed limit.
case onTrack
/// `below` quest that has exceeded its limit.
case failed
/// Period type not yet supported (monthly / custom).
case unsupported
}
/// A snapshot of a quest's accumulated value against its target for the
/// current period. `current` and `target` share units: seconds when
/// `usesTime`, discrete units otherwise.
struct QuestProgress {
let current: Double
let target: Double
let usesTime: Bool
let direction: QuestDirection
/// Whether the quest's period could actually be computed.
let isPeriodSupported: Bool
/// Completion ratio clamped to `0...1`, suitable for a `ProgressView`.
/// For `below` quests this represents how much of the budget is used, so
/// a full bar means the limit has been reached.
var fraction: Double {
guard target > 0 else { return 0 }
return max(0, min(current / target, 1))
}
var evaluation: QuestEvaluation {
guard isPeriodSupported else { return .unsupported }
switch direction {
case .above:
return current >= target ? .achieved : .inProgress
case .below:
return current > target ? .failed : .onTrack
}
}
}
@Observable
final class GoalViewModel {
// MARK: - Public API
/// Computes the current-period progress for `quest`, honoring the app's
/// logical day boundary (`dayStartHour`). Only `daily` and `weekly`
/// periods are supported; others return an unsupported, zero progress.
func progress(for quest: Quest, dayStartHour: Int = 0, now: Date = .now) -> QuestProgress {
guard let bounds = periodBounds(for: quest.period, dayStartHour: dayStartHour, now: now) else {
return QuestProgress(
current: 0,
target: quest.targetValue,
usesTime: quest.usesTime,
direction: quest.direction,
isPeriodSupported: false
)
}
let current: Double = quest.usesTime
? accumulatedTime(for: quest, start: bounds.start, end: bounds.end, now: now)
: accumulatedCount(for: quest, start: bounds.start, end: bounds.end)
return QuestProgress(
current: current,
target: quest.targetValue,
usesTime: quest.usesTime,
direction: quest.direction,
isPeriodSupported: true
)
}
// MARK: - Data sources
/// Time sessions relevant to the quest: a single action's, or the union of
/// all actions carrying the target tag.
private func timeSessions(for quest: Quest) -> [TimeSession] {
if let action = quest.targetAction { return action.timeSessions }
if let tag = quest.targetTag { return tag.actions.flatMap { $0.timeSessions } }
return []
}
/// Count entries relevant to the quest: a single action's, or the union of
/// all actions carrying the target tag.
private func countEntries(for quest: Quest) -> [CountEntry] {
if let action = quest.targetAction { return action.countEntries }
if let tag = quest.targetTag { return tag.actions.flatMap { $0.countEntries } }
return []
}
// MARK: - Accumulation
/// Total tracked duration (seconds) within `[start, end)`. Each session is
/// clipped to the window so boundary-crossing sessions only count their
/// overlapping portion; running sessions count up to `now`.
private func accumulatedTime(for quest: Quest, start: Date, end: Date, now: Date) -> TimeInterval {
timeSessions(for: quest).reduce(0) { total, session in
let sessionEnd = session.endTime ?? now
let overlapStart = max(session.startTime, start)
let overlapEnd = min(sessionEnd, end)
guard overlapEnd > overlapStart else { return total }
return total + overlapEnd.timeIntervalSince(overlapStart)
}
}
/// Sum of count entry amounts whose timestamp falls within `[start, end)`.
private func accumulatedCount(for quest: Quest, start: Date, end: Date) -> Double {
let entries = countEntries(for: quest)
var total = 0
for entry in entries where entry.timestamp >= start && entry.timestamp < end {
total += entry.amount
}
return Double(total)
}
// MARK: - Period bounds
/// The half-open `[start, end)` window for the current instance of `period`,
/// aligned to the logical day boundary. Returns `nil` for unsupported periods.
private func periodBounds(for period: QuestPeriod, dayStartHour: Int, now: Date) -> (start: Date, end: Date)? {
let calendar = Calendar.current
let dayStart = logicalDayStart(for: now, dayStartHour: dayStartHour, calendar: calendar)
switch period {
case .daily:
guard let end = calendar.date(byAdding: .day, value: 1, to: dayStart) else { return nil }
return (dayStart, end)
case .weekly:
// Anchor the week on the calendar day the logical day belongs to,
// then shift by the day-start offset so the week honors the boundary.
let weekComponents = calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: dayStart)
guard let weekMidnight = calendar.date(from: weekComponents),
let start = calendar.date(byAdding: .hour, value: dayStartHour, to: weekMidnight),
let end = calendar.date(byAdding: .day, value: 7, to: start)
else { return nil }
return (start, end)
case .monthly, .custom:
return nil
}
}
/// Start of the logical day containing `now`: the most recent occurrence of
/// `dayStartHour:00`. If `now` is before today's boundary, the logical day
/// began the previous calendar day.
private func logicalDayStart(for now: Date, dayStartHour: Int, calendar: Calendar) -> Date {
let midnight = calendar.startOfDay(for: now)
let boundary = calendar.date(byAdding: .hour, value: dayStartHour, to: midnight) ?? midnight
if now < boundary {
return calendar.date(byAdding: .day, value: -1, to: boundary) ?? boundary
}
return boundary
}
}