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:
@@ -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 iPhone↔Watch 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 iPhone↔Watch 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 1→2 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 {}
|
||||
|
||||
Reference in New Issue
Block a user