// // 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. `detail` is the one-line how-to-earn rule; `story` is /// the longer read shown on the badge's detail screen — why the milestone matters and /// what earning it testifies to. struct Achievement: Sendable, Equatable, Identifiable { var id: String var title: String var detail: String var story: 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, String)] = [ (1, "First Rep", "figure.walk", "Every training journey starts with a single session, and the first one is " + "the hardest rep of any program — it's the moment planning turned into " + "doing. This badge testifies to the step most people never take: you " + "showed up."), (10, "Ten Strong", "10.circle.fill", "Ten workouts is where a decision becomes a practice. Anyone can try " + "something once; coming back ten times means you pushed through the " + "early sessions where motivation does all the work and habit hasn't " + "formed yet. This badge testifies that you're building something real."), (50, "Fifty Club", "50.circle.fill", "Fifty sessions is months of consistent training — long enough for real " + "physiological change: heavier lifts, faster recovery, movements that " + "feel natural instead of awkward. This badge testifies to a body of " + "work, not a burst of enthusiasm."), (100, "Century", "medal.fill", "One hundred workouts represents hundreds of hours of deliberate effort, " + "and the kind of adaptation that only accumulates with time under load. " + "At this point training isn't something you do — it's part of who you " + "are. This badge testifies to identity, not intention."), (250, "Relentless", "flame.circle.fill", "Two hundred and fifty sessions is years of showing up — through busy " + "weeks, low motivation, and every good reason to skip. Very few people " + "ever train this much. This badge testifies to the rarest quality in " + "fitness: relentlessness."), ] for (target, title, symbol, story) in countMilestones { badges.append(Achievement( id: "count-\(target)", title: title, detail: "Complete \(target) workout\(target == 1 ? "" : "s")", story: story, 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, String)] = [ (4, "4-Week Rhythm", "Four straight weeks of hitting a goal is your first real training rhythm. " + "Progress comes from consistency week over week, not from one heroic " + "week — and a month is where a plan starts to become a pattern. This " + "badge testifies that you can sustain intent, not just spark it."), (8, "8-Week Habit", "Eight consecutive weeks is the length of a classic training block — the " + "horizon where measurable strength and conditioning gains actually show " + "up. Holding a streak this long means training survived vacations, " + "deadlines, and dips in motivation. This badge testifies that the habit " + "now carries you, not the other way around."), (12, "12-Week Lifestyle", "Twelve weeks — a full quarter — of hitting your goal every single week. " + "Programs end; lifestyles don't. A streak this long means training is no " + "longer an experiment you're running, it's simply how you live. This " + "badge testifies to exactly that."), ] for (target, title, story) in streakMilestones { badges.append(Achievement( id: "streak-\(target)", title: title, detail: "Hit a goal every week for \(target) weeks straight", story: story, 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", story: "A full week without a single day off takes planning, recovery " + "management, and follow-through every single morning. It testifies to " + "discipline and momentum — the ability to keep a promise to yourself " + "seven days running. Wear it proudly, and remember rest is training " + "too.", systemImage: "calendar", colorName: "green", earned: dayRun >= 7, progress: min(1, Double(dayRun) / 7))) // Lifetime volume. let volumeMilestones: [(Double, String, String, String)] = [ (100_000, "Heavy Lifter", "scalemass.fill", "A hundred thousand in lifted volume — every rep of every set, added up. " + "Volume is the primary driver of muscle growth, and a number this size " + "only comes from thousands of purposeful reps. This badge is the tonnage " + "receipt for your work: proof the effort was real and measured."), (1_000_000, "Million Mover", "trophy.fill", "One million in total volume. Nobody reaches seven figures by accident — " + "it's the compound interest of years of training, session after session, " + "plate after plate. This badge testifies to a scale of sustained work " + "that puts you in genuinely rare company."), ] for (target, title, symbol, story) in volumeMilestones { badges.append(Achievement( id: "volume-\(Int(target))", title: title, detail: "Lift \(Int(target).formatted()) total volume", story: story, 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", story: "Training before 7 AM means putting the workout ahead of everything " + "the day is about to throw at you. Morning sessions are the ones life " + "can't cancel — no meeting runs into them, no fatigue talks you out of " + "them. This badge testifies that you make time rather than find it.", 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", story: "Starting a workout after 9 PM takes a different kind of resolve — " + "willpower runs lowest at the end of the day, when the couch is " + "closest and every excuse is at hand. This badge testifies that you " + "train even when it would be easy not to.", 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", story: "In one week you hit every goal you set — not one focus at the " + "expense of the others, but the whole plan, executed. Balance is the " + "hardest thing to train because nothing about it is urgent. This " + "badge testifies to well-rounded programming and the discipline to " + "honor all of it at once.", 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 } }