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
+76 -12
View File
@@ -5,16 +5,16 @@ import IndieSync
/// entities so the wire format can evolve without dragging the cache schema.
///
/// One document = one aggregate root:
/// `SplitDocument` embeds its `[ExerciseDocument]` `Splits/<ULID>.json`
/// `RoutineDocument` embeds its `[ExerciseDocument]` `Splits/<ULID>.json`
/// `WorkoutDocument` embeds its `[WorkoutLogDocument]` `Workouts/YYYY/MM/<ULID>.json`
///
/// `schemaVersion` lets us migrate old files on read without forcing a rewrite
/// at sync time, and forward-gates files written by a newer app version.
/// These documents are also the wire format for the iPhoneWatch bridge.
// MARK: - Split
// MARK: - Routine
struct SplitDocument: Codable, Sendable, Equatable, Identifiable {
struct RoutineDocument: Codable, Sendable, Equatable, Identifiable {
var schemaVersion: Int
var id: String // ULID
var name: String
@@ -29,7 +29,7 @@ struct SplitDocument: Codable, Sendable, Equatable, Identifiable {
/// older app dropping it on rewrite (reverting to the default) is preferable to
/// quarantining the user's whole routine.
var activityType: Int?
/// Per-split rest length (seconds) and flow-mode toggle. Both optional and
/// Per-routine rest length (seconds) and flow-mode toggle. Both optional and
/// deliberately NOT schema-bumped, same rationale as `activityType`: an older
/// app dropping them on rewrite reverts to the global default rest / no-flow,
/// which is preferable to quarantining the user's whole routine.
@@ -46,6 +46,9 @@ struct SplitDocument: Codable, Sendable, Equatable, Identifiable {
// quarantine rather than rewrite.
static let currentSchemaVersion = 3
// PINNED: the iCloud directory name stays "Splits/" even though the type is now
// `RoutineDocument`. Existing user files live under Splits/ the directory name
// is on-disk data, not a symbol, and must not change on the rename.
var relativePath: String { "Splits/\(id).json" }
}
@@ -87,8 +90,8 @@ struct SetEntry: Codable, Sendable, Equatable {
struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
var schemaVersion: Int
var id: String // ULID (chronological)
var splitID: String?
var splitName: String?
var routineID: String?
var routineName: String?
var start: Date
var end: Date?
var status: String // WorkoutStatus raw value
@@ -109,13 +112,35 @@ struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
/// period. See `WorkoutMergePlanner`.
var deletedLogIDs: [String: Date]? = nil
/// Snapshot of the split's rest length / flow flag at plan time (a running
/// workout has no live link to its split, same reason sets/reps/weight are
/// Snapshot of the routine's rest length / flow flag at plan time (a running
/// workout has no live link to its routine, same reason sets/reps/weight are
/// snapshotted per log). Optional and not schema-bumped, same rationale as
/// `SplitDocument.restSeconds`/`autoAdvance`.
/// `RoutineDocument.restSeconds`/`autoAdvance`.
var restSeconds: Int? = nil
var autoAdvance: Bool? = nil
// PINNED CODING KEYS: the Swift properties `routineID`/`routineName` were renamed
// from `splitID`/`splitName`, but their on-disk (and iPhoneWatch wire) JSON keys
// stay `"splitID"`/`"splitName"` so existing files and a not-yet-updated peer still
// decode. Every stored property must be enumerated here (the computed `relativePath`
// and static members are not coded); all others keep their default key names.
enum CodingKeys: String, CodingKey {
case schemaVersion
case id
case routineID = "splitID"
case routineName = "splitName"
case start
case end
case status
case createdAt
case updatedAt
case logs
case metrics
case deletedLogIDs
case restSeconds
case autoAdvance
}
// Bumped 12 when `metrics` was added: the captured HR/calorie data is
// irreplaceable, so the forward-gate must quarantine these files on older apps
// rather than let them strip `metrics` on rewrite.
@@ -180,7 +205,7 @@ extension WorkoutDocument {
/// End the workout now, keeping progress: mark every not-completed log as skipped, then
/// recompute so it resolves to `.completed` (with `end` stamped). This is the
/// "End Workout Save" operation, shared by the in-workout menu and the
/// start-a-new-split prompt.
/// start-a-new-routine prompt.
mutating func endKeepingProgress() {
for i in logs.indices where (WorkoutStatus(rawValue: logs[i].status) ?? .notStarted) != .completed {
logs[i].transition(to: .skipped)
@@ -222,7 +247,7 @@ struct WorkoutLogDocument: Codable, Sendable, Equatable, Identifiable {
}
extension WorkoutLogDocument {
/// Build a fresh plan-time log from a split's exercise. Snapshots the plan
/// Build a fresh plan-time log from a routine's exercise. Snapshots the plan
/// fields (sets/reps/weight/duration) *and* the machine comfort settings, so a
/// running workout carries the exercise's settings as its own editable copy
/// `nil` stays `nil` (not a machine exercise), and `[]` stays `[]` (machine
@@ -337,9 +362,48 @@ struct WorkoutMetrics: Codable, Sendable, Equatable {
var recordedAt: Date
}
// MARK: - Schedule
/// The plan layer: one routine + a recurrence + an optional goal tag, persisted as
/// `Schedules/<ULID>.json`. The schedule is the join object between routines and
/// goals scheduling never touches the routine file (starter routines are
/// immutable fixed-ULID seeds).
struct ScheduleDocument: Codable, Sendable, Equatable, Identifiable {
var schemaVersion: Int
var id: String // ULID
var routineID: String
var routineName: String // denormalized display fallback (routine may be deleted)
var goal: String? // GoalKind raw value; nil = unassigned
var recurrence: String // ScheduleRecurrence raw value
var weekdays: [Int]? // Calendar weekday numbers 1 (Sun) 7 (Sat); .fixedDays only
var timesPerWeek: Int? // .frequency only
var date: Date? = nil // the one-off day; .once only
var order: Int
var createdAt: Date
var updatedAt: Date
static let currentSchemaVersion = 1
var relativePath: String { "Schedules/\(id).json" }
}
extension ScheduleDocument {
/// The tagged goal, or nil when unassigned / an unknown raw value.
var goalKind: GoalKind? { goal.flatMap(GoalKind.init(rawValue:)) }
/// The recurrence, defaulting to `.daily` on an unknown raw value.
var recurrenceEnum: ScheduleRecurrence { ScheduleRecurrence(rawValue: recurrence) ?? .daily }
/// Human-readable recurrence line ("Jul 12, 2026" / "Daily" / "Mon & Thu" / "2× per week").
var recurrenceSummary: String {
recurrenceEnum.summary(weekdays: weekdays, timesPerWeek: timesPerWeek, date: date)
}
}
// MARK: - Forward-compatibility gate
// `VersionedDocument` (the `isReadable` quarantine gate for files written by a
// newer app version), `Tombstone`, and `DocumentCoder` all live in IndieSync now.
extension SplitDocument: VersionedDocument {}
extension RoutineDocument: VersionedDocument {}
extension WorkoutDocument: VersionedDocument {}
extension ScheduleDocument: VersionedDocument {}