172 lines
6.6 KiB
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
|
|
}
|
|
}
|