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
+86 -23
View File
@@ -16,23 +16,24 @@ enum ScreenshotSeed {
}
/// Insert a believable in-progress workout, a few finished sessions (so charts have
/// a trend), and the starter splits. Returns the in-progress workout to display.
/// a trend), and the starter routines. Returns the in-progress workout to display.
@MainActor
@discardableResult
static func populate(_ context: ModelContext) -> Workout? {
// Idempotent: clear any prior seed so repeated launches don't stack duplicates
// (cascade deletes take the child exercises/logs with them).
try? context.delete(model: Workout.self)
try? context.delete(model: Split.self)
try? context.delete(model: Routine.self)
try? context.delete(model: Schedule.self)
let cal = Calendar(identifier: .gregorian)
let today = Date()
func daysAgo(_ n: Int) -> Date { cal.date(byAdding: .day, value: -n, to: today) ?? today }
// ---- Splits (with exercises) ------------------------------------------
// ---- Routines (with exercises) ------------------------------------------
// Names match the bundled Exercise Library rigs so every screen shows its
// animated figure (an unknown name would leave the figure slot empty).
let splits: [(String, String, String, [(String, Int, Int, Int)])] = [
let routines: [(String, String, String, [(String, Int, Int, Int)])] = [
("Upper Body", "blue", "figure.strengthtraining.traditional", [
("Lat Pull Down", 4, 10, 110), ("Seated Row", 4, 10, 90),
("Shoulder Press", 4, 10, 40), ("Arm Curl", 3, 12, 40),
@@ -47,44 +48,106 @@ enum ScreenshotSeed {
]),
]
for (sIndex, s) in splits.enumerated() {
let split = Split(id: ULID.make(), name: s.0, color: s.1, systemImage: s.2,
var created: [Routine] = []
for (sIndex, s) in routines.enumerated() {
let routine = Routine(id: ULID.make(), name: s.0, color: s.1, systemImage: s.2,
order: sIndex, createdAt: today, updatedAt: today, jsonRelativePath: "")
context.insert(split)
context.insert(routine)
created.append(routine)
for (eIndex, e) in s.3.enumerated() {
let isDuration = e.0 == "Plank"
let ex = Exercise(id: ULID.make(), name: e.0, order: eIndex, sets: e.1, reps: e.2,
weight: Double(e.3), loadType: (isDuration ? LoadType.duration : .weight).rawValue,
durationTotalSeconds: isDuration ? 45 : 0)
ex.split = split
ex.routine = routine
context.insert(ex)
}
}
let upper = splits[0]
// ---- Schedules (the Today board's rows, spanning two goal groups) -------
let schedulePlans: [(routine: Routine, goal: GoalKind, recurrence: ScheduleRecurrence,
weekdays: [Int]?, timesPerWeek: Int?)] = [
(created[0], .strength, .fixedDays, [2, 5], nil), // Upper Body · Mon & Thu
(created[1], .strength, .frequency, nil, 2), // Lower Body · 2× per week
(created[2], .mobility, .daily, nil, nil), // Core · daily
]
// Schedules predate the history below, so the Progress tab's adherence
// tracks cover the seeded weeks instead of starting "today".
let scheduledSince = daysAgo(70)
for (i, plan) in schedulePlans.enumerated() {
context.insert(Schedule(
id: ULID.make(), routineID: plan.routine.id, routineName: plan.routine.name,
goalRaw: plan.goal.rawValue, recurrenceRaw: plan.recurrence.rawValue,
weekdays: plan.weekdays, timesPerWeek: plan.timesPerWeek, order: i,
createdAt: scheduledSince, updatedAt: scheduledSince, jsonRelativePath: ""))
}
// ---- Past finished sessions (Lat Pull Down trend for the chart) --------
let liftTrend: [Double] = [95, 100, 105, 110]
for (i, w) in liftTrend.enumerated() {
let date = daysAgo((liftTrend.count - i) * 4)
let workout = Workout(id: ULID.make(), splitID: nil, splitName: upper.0, start: date,
end: date.addingTimeInterval(2400), statusRaw: WorkoutStatus.completed.rawValue,
createdAt: date, updatedAt: date, jsonRelativePath: "")
let upper = routines[0]
let upperID = created[0].id
// ---- Past finished sessions ---------------------------------------------
// Ten weeks of believable history across all three routines so Progress has
// living goal tracks, streaks, trends, and history: Upper Body lands on its
// Mon & Thu slots (one missed), Lower Body twice a week (one missed), Core
// most mornings unbroken in the last four weeks, so the Mobility streak is
// alive. Upper's Lat Pull Down ramps toward 110 for the progression chart.
// All deterministic patterns, not randomness.
func addCompleted(routine: Routine, spec: [(String, Int, Int, Int)],
on day: Date, hour: Int, lengthMinutes: Int, latPullDown: Double?) {
let start = cal.date(bySettingHour: hour, minute: 15, second: 0, of: day) ?? day
let end = start.addingTimeInterval(Double(lengthMinutes) * 60)
let workout = Workout(id: ULID.make(), routineID: routine.id, routineName: routine.name,
start: start, end: end, statusRaw: WorkoutStatus.completed.rawValue,
createdAt: start, updatedAt: end, jsonRelativePath: "")
context.insert(workout)
for (eIndex, e) in upper.3.enumerated() {
let weight = e.0 == "Lat Pull Down" ? w : Double(e.3)
for (eIndex, e) in spec.enumerated() {
let isDuration = e.0 == "Plank"
let weight = e.0 == "Lat Pull Down" ? (latPullDown ?? Double(e.3)) : Double(e.3)
let log = WorkoutLog(id: ULID.make(), exerciseName: e.0, order: eIndex, sets: e.1,
reps: e.2, weight: weight, loadType: LoadType.weight.rawValue,
durationTotalSeconds: 0, currentStateIndex: e.1,
statusRaw: WorkoutStatus.completed.rawValue, notes: nil, date: date,
setEntries: actualEntries(sets: e.1, reps: e.2, weight: weight, at: date))
reps: e.2, weight: isDuration ? 0 : weight,
loadType: (isDuration ? LoadType.duration : .weight).rawValue,
durationTotalSeconds: isDuration ? 45 : 0, currentStateIndex: e.1,
statusRaw: WorkoutStatus.completed.rawValue, notes: nil, date: start,
setEntries: isDuration ? nil
: actualEntries(sets: e.1, reps: e.2, weight: weight, at: start))
log.workout = workout
context.insert(log)
}
}
var upperSlot = 0, lowerSlot = 0
for d in stride(from: 69, through: 1, by: -1) {
let day = daysAgo(d)
let weekday = cal.component(.weekday, from: day)
// Upper Body Mon & Thu; the 5th slot is the believable miss.
if weekday == 2 || weekday == 5 {
upperSlot += 1
if upperSlot != 5 {
let lift = min(110, 87.5 + 2.5 * Double((upperSlot + 1) / 2))
addCompleted(routine: created[0], spec: routines[0].3,
on: day, hour: 17, lengthMinutes: 40, latPullDown: lift)
}
}
// Lower Body Tue & Sat, its 2×-per-week rhythm; the 4th slot missed.
if weekday == 3 || weekday == 7 {
lowerSlot += 1
if lowerSlot != 4 {
addCompleted(routine: created[1], spec: routines[1].3,
on: day, hour: 18, lengthMinutes: 35, latPullDown: nil)
}
}
// Core daily mornings; Sundays skipped beyond the last four weeks.
if d <= 28 || weekday != 1 {
addCompleted(routine: created[2], spec: routines[2].3,
on: day, hour: 6, lengthMinutes: 15, latPullDown: nil)
}
}
// ---- The current in-progress session -----------------------------------
let workout = Workout(id: ULID.make(), splitID: nil, splitName: upper.0, start: today,
// Carries the routine's id so the Today board's schedule row shows it as
// today's in-progress run.
let workout = Workout(id: ULID.make(), routineID: upperID, routineName: upper.0, start: today,
end: nil, statusRaw: WorkoutStatus.inProgress.rawValue,
createdAt: today, updatedAt: today, jsonRelativePath: "")
context.insert(workout)