// // ProgressPlanner.swift // Workouts // // Copyright 2026 Rouslan Zenetl. All Rights Reserved. // import Foundation // The Progress tab's derivation engine — a pure, unit-testable planner (precedent: // `SeedReconcilePlanner`). Everything here is a deterministic function of plain value // snapshots: per-schedule weekly adherence, per-goal rollups with streaks, trend // buckets for the charts, and the achievements grid. Nothing is ever persisted — // streaks and badges are recomputed from workout documents on every read, so they // survive cache rebuilds by construction (see UX-REDESIGN.md). // MARK: - Inputs /// A plain snapshot of a `Schedule` cache entity. `routineID` must already be /// live-resolved through `SyncEngine.currentRoutineID(for:)` so seed clone-on-edit /// redirects don't orphan history. struct ScheduleFacts: Sendable, Equatable { var id: String var goal: GoalKind? var recurrence: ScheduleRecurrence var weekdays: [Int]? // Calendar weekday numbers 1 (Sun) – 7 (Sat); .fixedDays only var onceDate: Date? // .once only var createdAt: Date var routineID: String // live-resolved var routineName: String } /// A plain snapshot of a `Workout` cache entity. `routineID` live-resolved like /// `ScheduleFacts.routineID`. `volume` is the caller's best value (recorded metric, /// else derived from set entries); `activeMinutes` is the completed duration. struct WorkoutFacts: Sendable, Equatable { var routineID: String? var start: Date var end: Date? var completed: Bool var volume: Double var activeMinutes: Double var energyKcal: Double } // MARK: - Adherence outputs /// One schedule-week (or goal-week) verdict: how many required units were met. /// `expected == 0` marks a *vacuous* week — the schedule imposed nothing (created /// mid-week past its fixed days, a one-off in another week); vacuous weeks neither /// break nor extend streaks. struct WeekMark: Sendable, Equatable { var weekStart: Date var met: Int var expected: Int var hit: Bool { met >= expected } var isVacuous: Bool { expected == 0 } /// Fill fraction for the week-strip squares (0…1). var fraction: Double { expected > 0 ? min(1, Double(met) / Double(expected)) : 0 } } /// One schedule's adherence across the track's week grid. `weeks` aligns 1:1 with the /// grid (oldest → newest); `nil` = the schedule didn't exist / doesn't apply that week. struct ScheduleTrack: Sendable, Equatable { var schedule: ScheduleFacts var weeks: [WeekMark?] /// The current week can no longer be met (a daily's missed day, or too few days /// left to cover a fixed day already missed). Distinct from "not yet hit". var currentWeekBroken: Bool var currentWeek: WeekMark? { weeks.last ?? nil } } /// A goal's rolled-up adherence: per-week sums across member schedules, plus the /// streak. The goal is the ledger category — its track continues unbroken across /// schedule rewrites (UX-REDESIGN.md). struct GoalTrack: Sendable, Equatable, Identifiable { var goal: GoalKind? // nil = the "Unassigned" group var scheduleTracks: [ScheduleTrack] var weeks: [WeekMark] // rollup, one per grid week /// Consecutive fully-hit weeks ending now. The current week counts once hit; /// merely "not yet hit but still possible" neither counts nor breaks. var streakWeeks: Int /// False when any member schedule's current week is already unmeetable. var streakAlive: Bool var id: String { goal?.rawValue ?? "unassigned" } } // MARK: - Trend outputs /// Chart time windows, per the trend-visualization pattern (7D/30D/90D/1Y/All). enum TrendRange: String, CaseIterable, Identifiable, Sendable { case week = "7D" case month = "30D" case quarter = "90D" case year = "1Y" case all = "All" var id: String { rawValue } func startDate(now: Date, calendar: Calendar) -> Date? { switch self { case .week: calendar.date(byAdding: .day, value: -7, to: now) case .month: calendar.date(byAdding: .day, value: -30, to: now) case .quarter: calendar.date(byAdding: .day, value: -90, to: now) case .year: calendar.date(byAdding: .year, value: -1, to: now) case .all: nil } } /// Bucket granularity: short ranges read daily, a quarter weekly, longer monthly. var bucket: Calendar.Component { switch self { case .week, .month: .day case .quarter: .weekOfYear case .year, .all: .month } } } /// One chart bucket (a day, week, or month depending on the range). struct TrendPoint: Sendable, Equatable, Identifiable { var date: Date // bucket start var workouts: Int var volume: Double var activeMinutes: Double var energyKcal: Double var id: Date { date } } // MARK: - Achievements /// A derived badge. `progress` is 0…1 toward earning (1 when earned) so locked badges /// can show how close they are. struct Achievement: Sendable, Equatable, Identifiable { var id: String var title: String var detail: String var systemImage: String var colorName: String var earned: Bool var progress: Double } // MARK: - Planner enum ProgressPlanner { // MARK: Goal tracks /// Roll schedules + workouts up into per-goal adherence tracks over the last /// `weeksBack` calendar weeks (oldest → newest, ending at the current week). /// Groups appear in `goalOrder`; schedules keep their input order within a group; /// goal-less schedules land in a trailing `goal == nil` group. Goals with no /// schedules produce no track. static func goalTracks( schedules: [ScheduleFacts], workouts: [WorkoutFacts], weeksBack: Int = 12, goalOrder: [GoalKind] = GoalKind.allCases, calendar: Calendar = .current, now: Date = Date() ) -> [GoalTrack] { let doneDays = completedDays(workouts: workouts, calendar: calendar) let grid = weekGrid(weeksBack: weeksBack, calendar: calendar, now: now) var groups: [(GoalKind?, [ScheduleFacts])] = goalOrder.map { kind in (kind, schedules.filter { $0.goal == kind }) } groups.append((nil, schedules.filter { $0.goal == nil })) return groups.compactMap { goal, members in guard !members.isEmpty else { return nil } let tracks = members.map { schedule in scheduleTrack(schedule: schedule, doneDays: doneDays[schedule.routineID] ?? [], grid: grid, calendar: calendar, now: now) } let rollup = grid.enumerated().map { index, weekStart in let marks = tracks.compactMap { $0.weeks[index] } return WeekMark(weekStart: weekStart, met: marks.reduce(0) { $0 + $1.met }, expected: marks.reduce(0) { $0 + $1.expected }) } let alive = !tracks.contains { $0.currentWeekBroken } return GoalTrack(goal: goal, scheduleTracks: tracks, weeks: rollup, streakWeeks: streak(weeks: rollup, alive: alive), streakAlive: alive) } } /// One schedule's marks across the grid plus its current-week brokenness. static func scheduleTrack( schedule: ScheduleFacts, doneDays: Set, grid: [Date], calendar: Calendar, now: Date ) -> ScheduleTrack { let weeks = grid.map { weekStart in weekMark(schedule: schedule, doneDays: doneDays, weekStart: weekStart, calendar: calendar, now: now) } let broken = grid.last.map { currentWeekStart in currentWeekBroken(schedule: schedule, doneDays: doneDays, weekStart: currentWeekStart, calendar: calendar, now: now) } ?? false return ScheduleTrack(schedule: schedule, weeks: weeks, currentWeekBroken: broken) } /// The verdict for one schedule-week, or nil when the schedule doesn't apply /// (created after the week, a one-off in a different week, a future week). /// /// Expectation semantics differ by recurrence so the *current* week reads as /// "on track so far" rather than "not done yet": /// - `.daily` / `.fixedDays`: expected counts only required days **up to today**; /// met counts distinct completed days in that same window (so a Tuesday make-up /// for a missed fixed Monday still counts — catch-ups are honored). /// - `.once`: expected 1, only in the week containing its date. static func weekMark( schedule: ScheduleFacts, doneDays: Set, weekStart: Date, calendar: Calendar, now: Date ) -> WeekMark? { guard let window = window(for: schedule, weekStart: weekStart, calendar: calendar, clampToDay: calendar.startOfDay(for: now)) else { return nil } let done = window.elapsedDays.filter(doneDays.contains).count switch schedule.recurrence { case .daily: return WeekMark(weekStart: weekStart, met: done, expected: window.elapsedDays.count) case .fixedDays: let wanted = Set(schedule.weekdays ?? []) let required = window.elapsedDays .filter { wanted.contains(calendar.component(.weekday, from: $0)) }.count return WeekMark(weekStart: weekStart, met: min(done, required), expected: required) case .once: guard let once = schedule.onceDate, calendar.dateInterval(of: .weekOfYear, for: once)?.start == weekStart else { return nil } return WeekMark(weekStart: weekStart, met: min(done, 1), expected: 1) } } /// True when the current week can no longer be met: the days remaining (today /// included) are fewer than the requirement still outstanding. A daily's missed /// yesterday breaks immediately; fixed days with catch-up days still left don't. static func currentWeekBroken( schedule: ScheduleFacts, doneDays: Set, weekStart: Date, calendar: Calendar, now: Date ) -> Bool { let today = calendar.startOfDay(for: now) guard let window = window(for: schedule, weekStart: weekStart, calendar: calendar, clampToDay: today) else { return false } let done = window.fullDays.filter(doneDays.contains).count let daysLeft = window.fullDays.filter { $0 >= today }.count let fullRequirement: Int switch schedule.recurrence { case .daily: fullRequirement = window.fullDays.count case .fixedDays: let wanted = Set(schedule.weekdays ?? []) fullRequirement = window.fullDays .filter { wanted.contains(calendar.component(.weekday, from: $0)) }.count case .once: guard let once = schedule.onceDate, calendar.dateInterval(of: .weekOfYear, for: once)?.start == weekStart else { return false } fullRequirement = 1 } return fullRequirement - done > daysLeft } // MARK: Trends /// Completed workouts bucketed for the chart: daily for 7D/30D, weekly for 90D, /// monthly for 1Y/All. Empty buckets are zero-filled from the range start (All: /// from the first workout) through now, so gaps read as gaps rather than the line /// skipping them. Empty input (or an empty window) returns []. static func trendPoints( workouts: [WorkoutFacts], range: TrendRange, calendar: Calendar = .current, now: Date = Date() ) -> [TrendPoint] { let cutoff = range.startDate(now: now, calendar: calendar) let completed = workouts.filter { workout in workout.completed && (cutoff.map { workout.start >= $0 } ?? true) } func bucketStart(_ date: Date) -> Date { calendar.dateInterval(of: range.bucket, for: date)?.start ?? date } var byBucket: [Date: TrendPoint] = [:] for workout in completed { let key = bucketStart(workout.start) var point = byBucket[key] ?? TrendPoint(date: key, workouts: 0, volume: 0, activeMinutes: 0, energyKcal: 0) point.workouts += 1 point.volume += workout.volume point.activeMinutes += workout.activeMinutes point.energyKcal += workout.energyKcal byBucket[key] = point } // Zero-fill from the window start so every bucket exists. let firstDate = cutoff ?? completed.map(\.start).min() guard let firstDate else { return [] } var points: [TrendPoint] = [] var cursor = bucketStart(firstDate) let last = bucketStart(now) while cursor <= last { points.append(byBucket[cursor] ?? TrendPoint(date: cursor, workouts: 0, volume: 0, activeMinutes: 0, energyKcal: 0)) guard let next = calendar.date(byAdding: range.bucket, value: 1, to: cursor), next > cursor else { break } cursor = next } return points } // MARK: Achievements /// The full badge set, earned and locked alike — every one a pure derivation of /// history (recomputable after any cache rebuild). Streak badges read the goal /// tracks' best run, so their reach is capped by the tracks' `weeksBack`. static func achievements( tracks: [GoalTrack], workouts: [WorkoutFacts], calendar: Calendar = .current, now: Date = Date() ) -> [Achievement] { let completed = workouts.filter(\.completed) let count = completed.count let totalVolume = completed.reduce(0) { $0 + $1.volume } var badges: [Achievement] = [] // Session-count milestones. let countMilestones: [(Int, String, String)] = [ (1, "First Rep", "figure.walk"), (10, "Ten Strong", "10.circle.fill"), (50, "Fifty Club", "50.circle.fill"), (100, "Century", "medal.fill"), (250, "Relentless", "flame.circle.fill"), ] for (target, title, symbol) in countMilestones { badges.append(Achievement( id: "count-\(target)", title: title, detail: "Complete \(target) workout\(target == 1 ? "" : "s")", systemImage: symbol, colorName: "blue", earned: count >= target, progress: min(1, Double(count) / Double(target)))) } // Weekly-streak milestones — best consecutive-hit run across any goal track. let bestRun = tracks.map { bestHitRun(weeks: $0.weeks) }.max() ?? 0 let streakMilestones: [(Int, String)] = [ (4, "4-Week Rhythm"), (8, "8-Week Habit"), (12, "12-Week Lifestyle"), ] for (target, title) in streakMilestones { badges.append(Achievement( id: "streak-\(target)", title: title, detail: "Hit a goal every week for \(target) weeks straight", systemImage: "flame.fill", colorName: "orange", earned: bestRun >= target, progress: min(1, Double(bestRun) / Double(target)))) } // Consecutive active days. let dayRun = longestDayRun(workouts: completed, calendar: calendar) badges.append(Achievement( id: "days-7", title: "Seven Straight", detail: "Work out 7 days in a row", systemImage: "calendar", colorName: "green", earned: dayRun >= 7, progress: min(1, Double(dayRun) / 7))) // Lifetime volume. let volumeMilestones: [(Double, String, String)] = [ (100_000, "Heavy Lifter", "scalemass.fill"), (1_000_000, "Million Mover", "trophy.fill"), ] for (target, title, symbol) in volumeMilestones { badges.append(Achievement( id: "volume-\(Int(target))", title: title, detail: "Lift \(Int(target).formatted()) total volume", systemImage: symbol, colorName: "purple", earned: totalVolume >= target, progress: min(1, totalVolume / target))) } // Time-of-day one-offs. let hours = completed.map { calendar.component(.hour, from: $0.start) } badges.append(Achievement( id: "early-bird", title: "Early Bird", detail: "Start a workout before 7 AM", systemImage: "sunrise.fill", colorName: "yellow", earned: hours.contains { $0 < 7 }, progress: hours.contains { $0 < 7 } ? 1 : 0)) badges.append(Achievement( id: "night-owl", title: "Night Owl", detail: "Start a workout after 9 PM", systemImage: "moon.stars.fill", colorName: "indigo", earned: hours.contains { $0 >= 21 }, progress: hours.contains { $0 >= 21 } ? 1 : 0)) // Clean sweep: some week in which every goal's requirement was fully hit. let sweep = cleanSweepExists(tracks: tracks) badges.append(Achievement( id: "clean-sweep", title: "Clean Sweep", detail: "Hit every goal in a single week", systemImage: "checkmark.seal.fill", colorName: "teal", earned: sweep, progress: sweep ? 1 : 0)) return badges } // MARK: - Shared helpers /// Start-of-day set of every day with a completed workout, keyed by routine ID. static func completedDays(workouts: [WorkoutFacts], calendar: Calendar) -> [String: Set] { var result: [String: Set] = [:] for workout in workouts where workout.completed { guard let routineID = workout.routineID else { continue } result[routineID, default: []].insert(calendar.startOfDay(for: workout.start)) } return result } /// Week starts oldest → newest, ending at the week containing `now`. static func weekGrid(weeksBack: Int, calendar: Calendar, now: Date) -> [Date] { guard weeksBack > 0, let currentStart = calendar.dateInterval(of: .weekOfYear, for: now)?.start else { return [] } return (0.. (fullDays: [Date], elapsedDays: [Date])? { guard let weekEnd = calendar.date(byAdding: .weekOfYear, value: 1, to: weekStart) else { return nil } let createdDay = calendar.startOfDay(for: schedule.createdAt) let start = max(weekStart, createdDay) guard start < weekEnd, start <= clampToDay else { return nil } var fullDays: [Date] = [] var cursor = start while cursor < weekEnd { fullDays.append(cursor) guard let next = calendar.date(byAdding: .day, value: 1, to: cursor), next > cursor else { break } cursor = next } let elapsedDays = fullDays.filter { $0 <= clampToDay } return (fullDays, elapsedDays) } /// Streak ending now: consecutive hit weeks scanned newest → oldest. A vacuous /// week is transparent (skipped); the current week counts once hit, is skipped /// while still meetable, and zeroes the streak when already broken. static func streak(weeks: [WeekMark], alive: Bool) -> Int { guard !weeks.isEmpty else { return 0 } var remaining = weeks let current = remaining.removeLast() var count = 0 if !current.isVacuous { if current.hit { count += 1 } else if !alive { return 0 } // else: still in play — neither counts nor breaks. } for week in remaining.reversed() { if week.isVacuous { continue } if week.hit { count += 1 } else { break } } return count } /// Longest run of consecutive hit weeks anywhere in the track (vacuous weeks are /// transparent here too). static func bestHitRun(weeks: [WeekMark]) -> Int { var best = 0, run = 0 for week in weeks { if week.isVacuous { continue } if week.hit { run += 1; best = max(best, run) } else { run = 0 } } return best } /// Longest run of consecutive calendar days with at least one completed workout. static func longestDayRun(workouts: [WorkoutFacts], calendar: Calendar) -> Int { let days = Set(workouts.filter(\.completed).map { calendar.startOfDay(for: $0.start) }) .sorted() var best = 0, run = 0 var previous: Date? for day in days { if let previous, let next = calendar.date(byAdding: .day, value: 1, to: previous), calendar.isDate(next, inSameDayAs: day) { run += 1 } else { run = 1 } best = max(best, run) previous = day } return best } /// True when some grid week saw every goal track's requirement fully hit — /// non-vacuously for at least two goals (a single-goal "sweep" is just a hit week). static func cleanSweepExists(tracks: [GoalTrack]) -> Bool { guard let weekCount = tracks.first?.weeks.count, !tracks.isEmpty else { return false } for index in 0..= 2 && marks.allSatisfy(\.hit) { return true } } return false } }