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
+14 -13
View File
@@ -4,30 +4,30 @@ import Testing
import IndieSync
@testable import Workouts
/// Locks the bundled starter-split library end to end: the catalog decodes and
/// Locks the bundled starter-routine library end to end: the catalog decodes and
/// orders correctly, the pinned seed ids never drift, a pristine save re-emits
/// byte-identical JSON (no phantom iCloud churn), every seed passes the
/// forward-compatibility gate, and `isPristine` tracks edits correctly through
/// both the raw document and a SwiftData cache round-trip.
struct SeedLibraryTests {
@Test func catalogLoadsThirteenSeedsInOrder() {
@Test func catalogLoadsFourteenSeedsInOrder() {
let seeds = SeedLibrary.seeds
#expect(seeds.count == 13)
#expect(seeds.map(\.doc.order) == Array(0...12))
#expect(seeds.count == 14)
#expect(seeds.map(\.doc.order) == Array(0...13))
#expect(seeds.map(\.doc.name) == [
"Upper Body", "Core", "Lower Body", "Bodyweight Core",
"Full Body Machines", "Free Weight Basics",
"Upper Body Warm-Up", "Lower Body Warm-Up",
"Morning Wake-Up", "Morning Mobility",
"Full Body Stretch", "Evening Stretch", "Cardio",
"Full Body Stretch", "Evening Stretch", "Cardio", "Meditation",
])
}
/// These ids are minted from a frozen 2020 timestamp and are already shipped
/// inside installed app bundles. Changing any of them breaks cross-device
/// convergence (two devices would mint different ids for "the same" starter
/// split) and voids the permanent-tombstone exemption that lets an
/// routine) and voids the permanent-tombstone exemption that lets an
/// edited-then-deleted seed's stub veto resurrection forever regenerate
/// only with extreme care, and never for a seed that has already shipped.
@Test func seedIDsArePinned() {
@@ -45,6 +45,7 @@ struct SeedLibraryTests {
"01DXF6DT003PRJB3FGYSSGTQ5X", // Full Body Stretch
"01DXF6DT00M5YRFPHJQ2FS2B9P", // Evening Stretch
"01DXF6DT00BXJ6N63ZBX9M9VNH", // Cardio
"01DXF6DT00ANFJR7GX9G808G4M", // Meditation
])
}
@@ -59,7 +60,7 @@ struct SeedLibraryTests {
@Test func allSeedsAreReadableAtCurrentSchemaVersion() {
for seed in SeedLibrary.seeds {
#expect(seed.doc.isReadable)
#expect(seed.doc.schemaVersion == SplitDocument.currentSchemaVersion)
#expect(seed.doc.schemaVersion == RoutineDocument.currentSchemaVersion)
}
}
@@ -102,13 +103,13 @@ struct SeedLibraryTests {
}
/// Round-trips every seed through the SwiftData cache the way the metadata
/// observer would (document `CacheMapper.upsertSplit` entity), then
/// observer would (document `CacheMapper.upsertRoutine` entity), then
/// rebuilds a document from that entity the way the write path would. The
/// result must still read as pristine, or every user's very first cache
/// rebuild would silently fork their starter splits.
/// rebuild would silently fork their starter routines.
@MainActor
@Test func mapperRoundTripPreservesPristineness() throws {
let schema = Schema([Split.self, Exercise.self, Workout.self, WorkoutLog.self])
let schema = Schema([Routine.self, Exercise.self, Workout.self, WorkoutLog.self])
// cloudKitDatabase: .none matches WorkoutsModelContainer without it SwiftData
// applies CloudKit schema validation (all relationships optional), which the
// cache entities intentionally don't satisfy.
@@ -117,9 +118,9 @@ struct SeedLibraryTests {
let context = ModelContext(container)
for seed in SeedLibrary.seeds {
CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
let entity = try #require(CacheMapper.fetchSplit(id: seed.id, in: context))
let rebuilt = SplitDocument(from: entity)
CacheMapper.upsertRoutine(seed.doc, relativePath: seed.doc.relativePath, into: context)
let entity = try #require(CacheMapper.fetchRoutine(id: seed.id, in: context))
let rebuilt = RoutineDocument(from: entity)
#expect(SeedLibrary.isPristine(rebuilt))
}
}