Files
workouts/Workouts/Progress/ProgressPlanner.swift
T

617 lines
27 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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<Date>,
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<Date>,
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<Date>,
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<Date>] {
var result: [String: Set<Date>] = [:]
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..<weeksBack).reversed().compactMap {
calendar.date(byAdding: .weekOfYear, value: -$0, to: currentStart)
}
}
/// The days of one schedule-week: `fullDays` is the whole week clipped to the
/// schedule's creation day; `elapsedDays` additionally clips to `clampToDay`
/// (today), for "so far" expectations. Nil when the schedule postdates the week.
private static func window(
for schedule: ScheduleFacts,
weekStart: Date,
calendar: Calendar,
clampToDay: Date
) -> (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..<weekCount {
let marks = tracks.compactMap { $0.weeks.indices.contains(index) ? $0.weeks[index] : nil }
let active = marks.filter { !$0.isVacuous }
if active.count >= 2 && marks.allSatisfy(\.hit) { return true }
}
return false
}
}