// // 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 } }