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:
@@ -7,14 +7,17 @@ import Foundation
|
||||
/// WatchConnectivity allows) keyed by stable ULIDs — no name/date reconciliation.
|
||||
enum WCPayload {
|
||||
static let typeKey = "type"
|
||||
static let splitsKey = "splits"
|
||||
// PINNED VALUES: the constant names became `routinesKey` / `editingRoutineIDKey`,
|
||||
// but their string VALUES stay `"splits"` / `"editingSplitID"` so an updated phone
|
||||
// still interops with a not-yet-updated watch over WatchConnectivity.
|
||||
static let routinesKey = "splits"
|
||||
static let workoutsKey = "workouts"
|
||||
static let workoutKey = "workout"
|
||||
static let restSecondsKey = "restSeconds"
|
||||
static let doneCountdownSecondsKey = "doneCountdownSeconds"
|
||||
static let weightUnitKey = "weightUnit"
|
||||
static let editingWorkoutIDKey = "editingWorkoutID"
|
||||
static let editingSplitIDKey = "editingSplitID"
|
||||
static let editingRoutineIDKey = "editingSplitID"
|
||||
|
||||
static let workoutUpdateType = "workoutUpdate" // watch → phone (one workout)
|
||||
static let requestSyncType = "requestSync" // watch → phone (please push state)
|
||||
@@ -23,31 +26,31 @@ enum WCPayload {
|
||||
|
||||
// MARK: - Phone → Watch (application context: latest-state-wins)
|
||||
|
||||
/// `editingWorkoutID` / `editingSplitID` are an exclusive-edit lock: while the phone
|
||||
/// has a workout's exercise (or a split) open in an editor, the watch parks any
|
||||
/// `editingWorkoutID` / `editingRoutineID` are an exclusive-edit lock: while the phone
|
||||
/// has a workout's exercise (or a routine) open in an editor, the watch parks any
|
||||
/// matching run and locks re-entry, so only one device owns the run at a time. They're
|
||||
/// part of the same latest-wins context — absent keys mean "not editing" (lock clear).
|
||||
static func encodeState(splits: [SplitDocument], workouts: [WorkoutDocument], restSeconds: Int, doneCountdownSeconds: Int, weightUnit: String, editingWorkoutID: String?, editingSplitID: String?) -> [String: Any] {
|
||||
static func encodeState(routines: [RoutineDocument], workouts: [WorkoutDocument], restSeconds: Int, doneCountdownSeconds: Int, weightUnit: String, editingWorkoutID: String?, editingRoutineID: String?) -> [String: Any] {
|
||||
var dict: [String: Any] = [:]
|
||||
if let s = try? DocumentCoder.encoder.encode(splits) { dict[splitsKey] = s }
|
||||
if let s = try? DocumentCoder.encoder.encode(routines) { dict[routinesKey] = s }
|
||||
if let w = try? DocumentCoder.encoder.encode(workouts) { dict[workoutsKey] = w }
|
||||
dict[restSecondsKey] = restSeconds
|
||||
dict[doneCountdownSecondsKey] = doneCountdownSeconds
|
||||
dict[weightUnitKey] = weightUnit
|
||||
if let editingWorkoutID { dict[editingWorkoutIDKey] = editingWorkoutID }
|
||||
if let editingSplitID { dict[editingSplitIDKey] = editingSplitID }
|
||||
if let editingRoutineID { dict[editingRoutineIDKey] = editingRoutineID }
|
||||
return dict
|
||||
}
|
||||
|
||||
/// `nil` means the payload carried splits that failed to decode (a schema mismatch
|
||||
/// `nil` means the payload carried routines that failed to decode (a schema mismatch
|
||||
/// between the two builds) — distinct from an absent key or a legitimately empty
|
||||
/// list, so the receiver can surface it instead of silently applying nothing.
|
||||
static func decodeSplits(_ dict: [String: Any]) -> [SplitDocument]? {
|
||||
guard let data = dict[splitsKey] as? Data else { return [] }
|
||||
return try? DocumentCoder.decoder.decode([SplitDocument].self, from: data)
|
||||
static func decodeRoutines(_ dict: [String: Any]) -> [RoutineDocument]? {
|
||||
guard let data = dict[routinesKey] as? Data else { return [] }
|
||||
return try? DocumentCoder.decoder.decode([RoutineDocument].self, from: data)
|
||||
}
|
||||
|
||||
/// See `decodeSplits` — `nil` is a decode failure, not an empty list.
|
||||
/// See `decodeRoutines` — `nil` is a decode failure, not an empty list.
|
||||
static func decodeWorkouts(_ dict: [String: Any]) -> [WorkoutDocument]? {
|
||||
guard let data = dict[workoutsKey] as? Data else { return [] }
|
||||
return try? DocumentCoder.decoder.decode([WorkoutDocument].self, from: data)
|
||||
@@ -61,7 +64,7 @@ enum WCPayload {
|
||||
|
||||
static func decodeEditingWorkoutID(_ dict: [String: Any]) -> String? { dict[editingWorkoutIDKey] as? String }
|
||||
|
||||
static func decodeEditingSplitID(_ dict: [String: Any]) -> String? { dict[editingSplitIDKey] as? String }
|
||||
static func decodeEditingRoutineID(_ dict: [String: Any]) -> String? { dict[editingRoutineIDKey] as? String }
|
||||
|
||||
// MARK: - Watch → Phone (a single updated workout)
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ extension WorkoutActivityType {
|
||||
case .cycling: .cycling
|
||||
case .warmUp: .preparationAndRecovery
|
||||
case .stretching: .flexibility
|
||||
case .mindAndBody: .mindAndBody
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
+69
-10
@@ -11,10 +11,10 @@ import SwiftData
|
||||
// SwiftData PersistentIdentifier). Computed helpers preserve the API the views
|
||||
// used against the old Core Data classes.
|
||||
|
||||
// MARK: - Split
|
||||
// MARK: - Routine
|
||||
|
||||
@Model
|
||||
final class Split {
|
||||
final class Routine {
|
||||
@Attribute(.unique) var id: String = ULID.make()
|
||||
var name: String = ""
|
||||
var color: String = "indigo"
|
||||
@@ -27,7 +27,7 @@ final class Split {
|
||||
var restSeconds: Int?
|
||||
var autoAdvance: Bool?
|
||||
|
||||
@Relationship(deleteRule: .cascade, inverse: \Exercise.split)
|
||||
@Relationship(deleteRule: .cascade, inverse: \Exercise.routine)
|
||||
var exercises: [Exercise] = []
|
||||
|
||||
init(id: String, name: String, color: String, systemImage: String, order: Int,
|
||||
@@ -42,7 +42,7 @@ final class Split {
|
||||
self.jsonRelativePath = jsonRelativePath
|
||||
}
|
||||
|
||||
static let unnamed = "Unnamed Split"
|
||||
static let unnamed = "Unnamed Routine"
|
||||
|
||||
var exercisesArray: [Exercise] { exercises.sorted { $0.order < $1.order } }
|
||||
|
||||
@@ -70,7 +70,7 @@ final class Exercise {
|
||||
// machine exercise; empty → a machine exercise with nothing recorded yet.
|
||||
var machineSettings: [MachineSetting]?
|
||||
|
||||
var split: Split?
|
||||
var routine: Routine?
|
||||
|
||||
init(id: String, name: String, order: Int, sets: Int, reps: Int, weight: Double,
|
||||
loadType: Int, durationTotalSeconds: Int, machineSettings: [MachineSetting]? = nil) {
|
||||
@@ -124,8 +124,8 @@ final class Exercise {
|
||||
@Model
|
||||
final class Workout {
|
||||
@Attribute(.unique) var id: String = ULID.make()
|
||||
var splitID: String?
|
||||
var splitName: String?
|
||||
var routineID: String?
|
||||
var routineName: String?
|
||||
var start: Date = Date()
|
||||
var end: Date?
|
||||
var statusRaw: String = WorkoutStatus.notStarted.rawValue
|
||||
@@ -156,11 +156,11 @@ final class Workout {
|
||||
@Relationship(deleteRule: .cascade, inverse: \WorkoutLog.workout)
|
||||
var logs: [WorkoutLog] = []
|
||||
|
||||
init(id: String, splitID: String?, splitName: String?, start: Date, end: Date?,
|
||||
init(id: String, routineID: String?, routineName: String?, start: Date, end: Date?,
|
||||
statusRaw: String, createdAt: Date, updatedAt: Date, jsonRelativePath: String) {
|
||||
self.id = id
|
||||
self.splitID = splitID
|
||||
self.splitName = splitName
|
||||
self.routineID = routineID
|
||||
self.routineName = routineName
|
||||
self.start = start
|
||||
self.end = end
|
||||
self.statusRaw = statusRaw
|
||||
@@ -174,6 +174,14 @@ final class Workout {
|
||||
set { statusRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
/// True while this workout is an untouched draft: created by a start tap, but no
|
||||
/// exercise has ever left `.notStarted`. Peeking into a routine must leave no
|
||||
/// trace, so the start paths discard a pristine draft when the user backs out of
|
||||
/// it instead of persisting a phantom "in progress" workout.
|
||||
var isPristineDraft: Bool {
|
||||
status == .notStarted && logs.allSatisfy { $0.status == .notStarted }
|
||||
}
|
||||
|
||||
var statusName: String { status.displayName }
|
||||
|
||||
var logsArray: [WorkoutLog] { logs.sorted { $0.order < $1.order } }
|
||||
@@ -298,3 +306,54 @@ final class WorkoutLog {
|
||||
set { durationTotalSeconds = durationMinutes * 60 + newValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Schedule
|
||||
|
||||
@Model
|
||||
final class Schedule {
|
||||
@Attribute(.unique) var id: String = ULID.make()
|
||||
var routineID: String = ""
|
||||
var routineName: String = ""
|
||||
var goalRaw: String?
|
||||
var recurrenceRaw: String = ScheduleRecurrence.daily.rawValue
|
||||
var weekdays: [Int]?
|
||||
var timesPerWeek: Int?
|
||||
var date: Date?
|
||||
var order: Int = 0
|
||||
var createdAt: Date = Date()
|
||||
var updatedAt: Date = Date()
|
||||
var jsonRelativePath: String = ""
|
||||
|
||||
init(id: String, routineID: String, routineName: String, goalRaw: String?,
|
||||
recurrenceRaw: String, weekdays: [Int]?, timesPerWeek: Int?, date: Date? = nil,
|
||||
order: Int, createdAt: Date, updatedAt: Date, jsonRelativePath: String) {
|
||||
self.id = id
|
||||
self.routineID = routineID
|
||||
self.routineName = routineName
|
||||
self.goalRaw = goalRaw
|
||||
self.recurrenceRaw = recurrenceRaw
|
||||
self.weekdays = weekdays
|
||||
self.timesPerWeek = timesPerWeek
|
||||
self.date = date
|
||||
self.order = order
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
self.jsonRelativePath = jsonRelativePath
|
||||
}
|
||||
|
||||
var goalKind: GoalKind? {
|
||||
get { goalRaw.flatMap(GoalKind.init(rawValue:)) }
|
||||
set { goalRaw = newValue?.rawValue }
|
||||
}
|
||||
|
||||
var recurrenceEnum: ScheduleRecurrence {
|
||||
get { ScheduleRecurrence(rawValue: recurrenceRaw) ?? .daily }
|
||||
set { recurrenceRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
/// Human-readable recurrence line — shares its formatting with `ScheduleDocument`
|
||||
/// via `ScheduleRecurrence.summary(weekdays:timesPerWeek:date:)`.
|
||||
var recurrenceSummary: String {
|
||||
recurrenceEnum.summary(weekdays: weekdays, timesPerWeek: timesPerWeek, date: date)
|
||||
}
|
||||
}
|
||||
|
||||
+128
-2
@@ -35,9 +35,9 @@ enum LoadType: Int, CaseIterable, Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind of training a split represents — used to tag the watch's Apple Health
|
||||
/// The kind of training a routine represents — used to tag the watch's Apple Health
|
||||
/// workout so it lands in the right category (and credits the rings correctly).
|
||||
/// Persisted as its raw `Int` on `SplitDocument`; the `HKWorkoutActivityType`
|
||||
/// Persisted as its raw `Int` on `RoutineDocument`; the `HKWorkoutActivityType`
|
||||
/// mapping lives in `Shared/HealthKit/HealthKitMapping.swift` to keep this enum
|
||||
/// HealthKit-free.
|
||||
enum WorkoutActivityType: Int, CaseIterable, Codable, Sendable {
|
||||
@@ -49,6 +49,7 @@ enum WorkoutActivityType: Int, CaseIterable, Codable, Sendable {
|
||||
case cycling = 5
|
||||
case warmUp = 6
|
||||
case stretching = 7
|
||||
case mindAndBody = 8
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
@@ -60,6 +61,7 @@ enum WorkoutActivityType: Int, CaseIterable, Codable, Sendable {
|
||||
case .cycling: "Cycling"
|
||||
case .warmUp: "Warm-Up"
|
||||
case .stretching: "Stretching"
|
||||
case .mindAndBody: "Mind & Body"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +75,7 @@ enum WorkoutActivityType: Int, CaseIterable, Codable, Sendable {
|
||||
case .cycling: "figure.outdoor.cycle"
|
||||
case .warmUp: "figure.arms.open"
|
||||
case .stretching: "figure.flexibility"
|
||||
case .mindAndBody: "figure.mind.and.body"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,3 +122,126 @@ enum WeightUnit: String, CaseIterable, Codable, Sendable {
|
||||
/// Render a stored weight value with the current unit's label, e.g. "135 lb".
|
||||
func format(_ value: Int) -> String { "\(value) \(abbreviation)" }
|
||||
}
|
||||
|
||||
// MARK: - Goals
|
||||
|
||||
/// The predefined practice-goal categories. Deliberately a small, closed set —
|
||||
/// schedules reference them by raw value, so raw values are stable persisted data.
|
||||
enum GoalKind: String, CaseIterable, Codable, Sendable, Identifiable {
|
||||
// Declaration order IS the initial board order (before any user reorder):
|
||||
// the day reads top-to-bottom — morning mobility, then cardio, then strength,
|
||||
// with mindfulness closing it out.
|
||||
case mobility, cardio, strength, mindfulness
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .strength: "Strength"
|
||||
// Deliberately double-barreled: the same practice serves performance
|
||||
// ("endurance") and health ("cardio") motivations — the name excludes neither.
|
||||
case .cardio: "Endurance/Cardio"
|
||||
case .mobility: "Mobility"
|
||||
case .mindfulness: "Mindfulness"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .strength: "dumbbell.fill"
|
||||
case .cardio: "figure.run"
|
||||
case .mobility: "figure.flexibility"
|
||||
case .mindfulness: "brain.head.profile"
|
||||
}
|
||||
}
|
||||
|
||||
/// A hue name `Color.color(from:)` understands. Four distinct hues so the goals
|
||||
/// read apart at a glance.
|
||||
var colorName: String {
|
||||
switch self {
|
||||
case .strength: "red"
|
||||
case .cardio: "orange"
|
||||
case .mobility: "teal"
|
||||
case .mindfulness: "purple"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension GoalKind {
|
||||
static let orderDefaultsKey = "goalKindOrder"
|
||||
|
||||
/// All cases in the user's chosen order: stored raw values first (unknown ones
|
||||
/// dropped, duplicates collapsed), then any cases missing from the stored list in
|
||||
/// declaration order — so newly added kinds appear automatically.
|
||||
static func orderedCases(defaults: UserDefaults = .standard) -> [GoalKind] {
|
||||
let stored = defaults.stringArray(forKey: orderDefaultsKey) ?? []
|
||||
var ordered: [GoalKind] = []
|
||||
var seen = Set<GoalKind>()
|
||||
for raw in stored {
|
||||
if let kind = GoalKind(rawValue: raw), seen.insert(kind).inserted {
|
||||
ordered.append(kind)
|
||||
}
|
||||
}
|
||||
for kind in GoalKind.allCases where !seen.contains(kind) {
|
||||
ordered.append(kind)
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
/// Persist the display order as `[String]` of raw values.
|
||||
static func saveOrder(_ kinds: [GoalKind], defaults: UserDefaults = .standard) {
|
||||
defaults.set(kinds.map(\.rawValue), forKey: orderDefaultsKey)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Schedules
|
||||
|
||||
/// How often a schedule expects its routine. Raw values are persisted in ScheduleDocument.
|
||||
enum ScheduleRecurrence: String, CaseIterable, Codable, Sendable {
|
||||
case once, daily, fixedDays, frequency
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .once: "Once"
|
||||
case .daily: "Daily"
|
||||
case .fixedDays: "Fixed Days"
|
||||
case .frequency: "Times per Week"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ScheduleRecurrence {
|
||||
/// Human-readable recurrence description shared by `ScheduleDocument` and the
|
||||
/// `Schedule` cache entity, so both render identically. `weekdays` uses Calendar
|
||||
/// weekday numbers (1 = Sun … 7 = Sat); `timesPerWeek` is the frequency count;
|
||||
/// `date` is the one-off day (`.once` only). A degenerate case (fixed days with no
|
||||
/// weekdays, a nil count or date) falls back to the recurrence's own `displayName`.
|
||||
func summary(weekdays: [Int]?, timesPerWeek: Int?, date: Date?) -> String {
|
||||
switch self {
|
||||
case .once:
|
||||
return date?.formatDate() ?? displayName // "Jul 12, 2026" / "Once"
|
||||
case .daily:
|
||||
return displayName // "Daily"
|
||||
case .fixedDays:
|
||||
// A fixed Gregorian calendar's `shortWeekdaySymbols` is 0-indexed from
|
||||
// Sunday, so weekday number `n` maps to index `n - 1` regardless of locale.
|
||||
let symbols = Calendar(identifier: .gregorian).shortWeekdaySymbols
|
||||
let names = (weekdays ?? []).sorted().compactMap { n -> String? in
|
||||
(1...7).contains(n) ? symbols[n - 1] : nil
|
||||
}
|
||||
return names.isEmpty ? displayName : Self.joined(names)
|
||||
case .frequency:
|
||||
return "\(timesPerWeek ?? 0)× per week"
|
||||
}
|
||||
}
|
||||
|
||||
/// Join weekday short names conversationally: "Mon", "Mon & Thu", "Mon, Wed & Fri".
|
||||
private static func joined(_ names: [String]) -> String {
|
||||
switch names.count {
|
||||
case 0: return ""
|
||||
case 1: return names[0]
|
||||
case 2: return "\(names[0]) & \(names[1])"
|
||||
default: return names.dropLast().joined(separator: ", ") + " & " + (names.last ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+79
-35
@@ -21,14 +21,14 @@ extension ExerciseDocument {
|
||||
}
|
||||
}
|
||||
|
||||
extension SplitDocument {
|
||||
init(from split: Split) {
|
||||
self.init(schemaVersion: Self.currentSchemaVersion, id: split.id, name: split.name,
|
||||
color: split.color, systemImage: split.systemImage, order: split.order,
|
||||
createdAt: split.createdAt, updatedAt: split.updatedAt,
|
||||
exercises: split.exercisesArray.map(ExerciseDocument.init(from:)),
|
||||
activityType: split.activityTypeRaw,
|
||||
restSeconds: split.restSeconds, autoAdvance: split.autoAdvance)
|
||||
extension RoutineDocument {
|
||||
init(from routine: Routine) {
|
||||
self.init(schemaVersion: Self.currentSchemaVersion, id: routine.id, name: routine.name,
|
||||
color: routine.color, systemImage: routine.systemImage, order: routine.order,
|
||||
createdAt: routine.createdAt, updatedAt: routine.updatedAt,
|
||||
exercises: routine.exercisesArray.map(ExerciseDocument.init(from:)),
|
||||
activityType: routine.activityTypeRaw,
|
||||
restSeconds: routine.restSeconds, autoAdvance: routine.autoAdvance)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ extension WorkoutLogDocument {
|
||||
|
||||
extension WorkoutDocument {
|
||||
init(from workout: Workout) {
|
||||
self.init(schemaVersion: Self.currentSchemaVersion, id: workout.id, splitID: workout.splitID,
|
||||
splitName: workout.splitName, start: workout.start, end: workout.end,
|
||||
self.init(schemaVersion: Self.currentSchemaVersion, id: workout.id, routineID: workout.routineID,
|
||||
routineName: workout.routineName, start: workout.start, end: workout.end,
|
||||
status: workout.statusRaw, createdAt: workout.createdAt, updatedAt: workout.updatedAt,
|
||||
logs: workout.logsArray.map(WorkoutLogDocument.init(from:)),
|
||||
metrics: workout.metrics,
|
||||
@@ -86,11 +86,22 @@ extension WorkoutDocument {
|
||||
logs: [])
|
||||
}
|
||||
|
||||
extension ScheduleDocument {
|
||||
init(from schedule: Schedule) {
|
||||
self.init(schemaVersion: Self.currentSchemaVersion, id: schedule.id,
|
||||
routineID: schedule.routineID, routineName: schedule.routineName,
|
||||
goal: schedule.goalRaw, recurrence: schedule.recurrenceRaw,
|
||||
weekdays: schedule.weekdays, timesPerWeek: schedule.timesPerWeek,
|
||||
date: schedule.date,
|
||||
order: schedule.order, createdAt: schedule.createdAt, updatedAt: schedule.updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Document → Cache (upsert)
|
||||
|
||||
enum CacheMapper {
|
||||
static func fetchSplit(id: String, in context: ModelContext) -> Split? {
|
||||
var d = FetchDescriptor<Split>(predicate: #Predicate { $0.id == id })
|
||||
static func fetchRoutine(id: String, in context: ModelContext) -> Routine? {
|
||||
var d = FetchDescriptor<Routine>(predicate: #Predicate { $0.id == id })
|
||||
d.fetchLimit = 1
|
||||
return try? context.fetch(d).first
|
||||
}
|
||||
@@ -101,30 +112,36 @@ enum CacheMapper {
|
||||
return try? context.fetch(d).first
|
||||
}
|
||||
|
||||
// MARK: Split
|
||||
static func fetchSchedule(id: String, in context: ModelContext) -> Schedule? {
|
||||
var d = FetchDescriptor<Schedule>(predicate: #Predicate { $0.id == id })
|
||||
d.fetchLimit = 1
|
||||
return try? context.fetch(d).first
|
||||
}
|
||||
|
||||
static func upsertSplit(_ doc: SplitDocument, relativePath: String, into context: ModelContext) {
|
||||
let split: Split
|
||||
if let existing = fetchSplit(id: doc.id, in: context) {
|
||||
split = existing
|
||||
// MARK: Routine
|
||||
|
||||
static func upsertRoutine(_ doc: RoutineDocument, relativePath: String, into context: ModelContext) {
|
||||
let routine: Routine
|
||||
if let existing = fetchRoutine(id: doc.id, in: context) {
|
||||
routine = existing
|
||||
} else {
|
||||
split = Split(id: doc.id, name: doc.name, color: doc.color, systemImage: doc.systemImage,
|
||||
routine = Routine(id: doc.id, name: doc.name, color: doc.color, systemImage: doc.systemImage,
|
||||
order: doc.order, createdAt: doc.createdAt, updatedAt: doc.updatedAt,
|
||||
jsonRelativePath: relativePath)
|
||||
context.insert(split)
|
||||
context.insert(routine)
|
||||
}
|
||||
split.name = doc.name
|
||||
split.color = doc.color
|
||||
split.systemImage = doc.systemImage
|
||||
split.order = doc.order
|
||||
split.createdAt = doc.createdAt
|
||||
split.updatedAt = doc.updatedAt
|
||||
split.jsonRelativePath = relativePath
|
||||
split.activityTypeRaw = doc.activityType ?? 0
|
||||
split.restSeconds = doc.restSeconds
|
||||
split.autoAdvance = doc.autoAdvance
|
||||
routine.name = doc.name
|
||||
routine.color = doc.color
|
||||
routine.systemImage = doc.systemImage
|
||||
routine.order = doc.order
|
||||
routine.createdAt = doc.createdAt
|
||||
routine.updatedAt = doc.updatedAt
|
||||
routine.jsonRelativePath = relativePath
|
||||
routine.activityTypeRaw = doc.activityType ?? 0
|
||||
routine.restSeconds = doc.restSeconds
|
||||
routine.autoAdvance = doc.autoAdvance
|
||||
|
||||
let existing = Dictionary(split.exercises.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
let existing = Dictionary(routine.exercises.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
|
||||
var keep = Set<String>()
|
||||
for ed in doc.exercises {
|
||||
keep.insert(ed.id)
|
||||
@@ -132,11 +149,11 @@ enum CacheMapper {
|
||||
apply(ed, to: e)
|
||||
} else {
|
||||
let e = makeExercise(ed)
|
||||
e.split = split
|
||||
e.routine = routine
|
||||
context.insert(e)
|
||||
}
|
||||
}
|
||||
for e in Array(split.exercises) where !keep.contains(e.id) {
|
||||
for e in Array(routine.exercises) where !keep.contains(e.id) {
|
||||
context.delete(e)
|
||||
}
|
||||
}
|
||||
@@ -165,14 +182,14 @@ enum CacheMapper {
|
||||
if let existing = fetchWorkout(id: doc.id, in: context) {
|
||||
workout = existing
|
||||
} else {
|
||||
workout = Workout(id: doc.id, splitID: doc.splitID, splitName: doc.splitName,
|
||||
workout = Workout(id: doc.id, routineID: doc.routineID, routineName: doc.routineName,
|
||||
start: doc.start, end: doc.end, statusRaw: doc.status,
|
||||
createdAt: doc.createdAt, updatedAt: doc.updatedAt,
|
||||
jsonRelativePath: relativePath)
|
||||
context.insert(workout)
|
||||
}
|
||||
workout.splitID = doc.splitID
|
||||
workout.splitName = doc.splitName
|
||||
workout.routineID = doc.routineID
|
||||
workout.routineName = doc.routineName
|
||||
workout.start = doc.start
|
||||
workout.end = doc.end
|
||||
workout.statusRaw = doc.status
|
||||
@@ -228,4 +245,31 @@ enum CacheMapper {
|
||||
l.setEntries = d.setEntries
|
||||
l.logUpdatedAt = d.updatedAt
|
||||
}
|
||||
|
||||
// MARK: Schedule
|
||||
|
||||
static func upsertSchedule(_ doc: ScheduleDocument, relativePath: String, into context: ModelContext) {
|
||||
let schedule: Schedule
|
||||
if let existing = fetchSchedule(id: doc.id, in: context) {
|
||||
schedule = existing
|
||||
} else {
|
||||
schedule = Schedule(id: doc.id, routineID: doc.routineID, routineName: doc.routineName,
|
||||
goalRaw: doc.goal, recurrenceRaw: doc.recurrence, weekdays: doc.weekdays,
|
||||
timesPerWeek: doc.timesPerWeek, date: doc.date, order: doc.order,
|
||||
createdAt: doc.createdAt, updatedAt: doc.updatedAt,
|
||||
jsonRelativePath: relativePath)
|
||||
context.insert(schedule)
|
||||
}
|
||||
schedule.routineID = doc.routineID
|
||||
schedule.routineName = doc.routineName
|
||||
schedule.goalRaw = doc.goal
|
||||
schedule.recurrenceRaw = doc.recurrence
|
||||
schedule.weekdays = doc.weekdays
|
||||
schedule.timesPerWeek = doc.timesPerWeek
|
||||
schedule.date = doc.date
|
||||
schedule.order = doc.order
|
||||
schedule.createdAt = doc.createdAt
|
||||
schedule.updatedAt = doc.updatedAt
|
||||
schedule.jsonRelativePath = relativePath
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,16 @@ import SwiftData
|
||||
/// safe; `SyncEngine` repopulates it from the container on next launch.
|
||||
enum WorkoutsModelContainer {
|
||||
/// Bump whenever the cache schema changes — wipes and rebuilds from files.
|
||||
/// 2: added health-metric columns to `Workout` and `activityTypeRaw` to `Split`.
|
||||
/// 2: added health-metric columns to `Workout` and `activityTypeRaw` to `Routine`.
|
||||
/// 3: added `categoryRaw` to `Exercise`.
|
||||
/// 4: removed the weight-reminder columns and `categoryRaw` from `Exercise` and
|
||||
/// the `completed` column from `WorkoutLog`; added `machineSettings` to both.
|
||||
/// 5: `weight` went Int → Double on `Exercise` and `WorkoutLog`; added
|
||||
/// `setEntries` and `logUpdatedAt` to `WorkoutLog`.
|
||||
/// 6: added `deletedLogIDs` to `Workout` (per-log merge deletion tombstones).
|
||||
static let currentSchemaVersion = 6
|
||||
/// 7: renamed `Split` entity to `Routine`.
|
||||
/// 8: added `Schedule` cache entity.
|
||||
static let currentSchemaVersion = 8
|
||||
private static let schemaVersionKey = "Workouts.persistenceSchemaVersion"
|
||||
private static let identityTokenKey = "Workouts.iCloudIdentityToken"
|
||||
|
||||
@@ -30,7 +32,7 @@ enum WorkoutsModelContainer {
|
||||
}
|
||||
|
||||
static func make() -> ModelContainer {
|
||||
let schema = Schema([Split.self, Exercise.self, Workout.self, WorkoutLog.self])
|
||||
let schema = Schema([Routine.self, Exercise.self, Workout.self, WorkoutLog.self, Schedule.self])
|
||||
ensureStoreDirectoryExists()
|
||||
wipeIfNeeded()
|
||||
wipeIfAccountChanged()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -43,7 +43,7 @@ extension Color {
|
||||
}
|
||||
}
|
||||
|
||||
// Canonical palettes for splits (single source of truth).
|
||||
// Canonical palettes for routines (single source of truth).
|
||||
let availableColors = ["red", "orange", "yellow", "green", "mint", "teal", "cyan", "blue", "indigo", "purple", "pink", "brown"]
|
||||
|
||||
let availableIcons = ["dumbbell.fill", "figure.strengthtraining.traditional", "figure.run", "figure.hiking", "figure.cooldown", "figure.boxing", "figure.wrestling", "figure.gymnastics", "figure.handball", "figure.core.training", "heart.fill", "bolt.fill"]
|
||||
|
||||
Reference in New Issue
Block a user