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
This commit is contained in:
2026-07-11 07:53:01 -04:00
parent 5c201289fb
commit 6e440317c4
97 changed files with 5354 additions and 1291 deletions
+557
View File
@@ -0,0 +1,557 @@
//
// 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 (01).
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 01 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
}
}