Files
workouts/Workouts/Progress/ProgressPlanner.swift
T
rzen 6e440317c4 Restructure into a three-tab app with Progress, goals, and Meditation
The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView
becomes a Today / Progress / Settings TabView, "Routine" replaces
"Split" in every user-facing string and view name (code-level types
keep their names), and workout starting moves to shared
WorkoutStarter / StartedWorkoutNavigator plumbing.

- New Progress tab: weekly goal streaks, workout trends, per-exercise
  weight progression, achievements, and the full history list
  (WorkoutLogsView -> WorkoutHistoryView).
- Goals: stable categories workouts roll up to, managed from Settings.
- New Meditation exercise + starter routine; timed sits record to
  Apple Health as Mind & Body sessions.

Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
2026-07-11 07:53:01 -04:00

558 lines
22 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 timesPerWeek: Int? // .frequency 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, too few days left
/// for the remaining frequency count). 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<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).
/// - `.frequency`: expected is the full weekly target (clamped to the days the
/// schedule existed that week); mid-week it reads "1 of 2" until actually met.
/// - `.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 .frequency:
let target = min(max(schedule.timesPerWeek ?? 0, 0), window.fullDays.count)
return WeekMark(weekStart: weekStart, met: min(done, target), expected: target)
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; a 2×/week with one done and two days left doesn'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 .frequency:
fullRequirement = min(max(schedule.timesPerWeek ?? 0, 0), window.fullDays.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<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
}
}