e2295aa287
Replace the in-workout "+" toolbar button with an ellipsis menu offering "Add Exercise" and "End Workout". Ending opens a Save/Discard action sheet: Save marks the remaining exercises as skipped and resolves the workout to completed (stamping end), which drops it off the watch's active list and ends the watch's HealthKit session; Discard soft-deletes it. Teach the status-from-logs derivation that a skipped log is terminal, and consolidate the three duplicated copies into a single shared WorkoutDocument.recomputeStatusFromLogs() so an ended workout stays finished regardless of which screen the next edit comes from. Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
172 lines
5.7 KiB
Swift
172 lines
5.7 KiB
Swift
import Foundation
|
|
|
|
/// On-disk JSON shape for each aggregate. Independent from the SwiftData cache
|
|
/// entities so the wire format can evolve without dragging the cache schema.
|
|
///
|
|
/// One document = one aggregate root:
|
|
/// • `SplitDocument` 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
|
|
|
|
struct SplitDocument: Codable, Sendable, Equatable, Identifiable {
|
|
var schemaVersion: Int
|
|
var id: String // ULID
|
|
var name: String
|
|
var color: String
|
|
var systemImage: String
|
|
var order: Int
|
|
var createdAt: Date
|
|
var updatedAt: Date
|
|
var exercises: [ExerciseDocument]
|
|
|
|
static let currentSchema = 1
|
|
|
|
var relativePath: String { "Splits/\(id).json" }
|
|
}
|
|
|
|
struct ExerciseDocument: Codable, Sendable, Equatable, Identifiable {
|
|
var id: String // ULID
|
|
var name: String
|
|
var order: Int
|
|
var sets: Int
|
|
var reps: Int
|
|
var weight: Int
|
|
var loadType: Int
|
|
var durationSeconds: Int // total seconds (0 when not a timed exercise)
|
|
var weightLastUpdated: Date?
|
|
var weightReminderWeeks: Int
|
|
}
|
|
|
|
// MARK: - Workout
|
|
|
|
struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
|
|
var schemaVersion: Int
|
|
var id: String // ULID (chronological)
|
|
var splitID: String?
|
|
var splitName: String?
|
|
var start: Date
|
|
var end: Date?
|
|
var status: String // WorkoutStatus raw value
|
|
var createdAt: Date
|
|
var updatedAt: Date
|
|
var logs: [WorkoutLogDocument]
|
|
|
|
static let currentSchema = 1
|
|
|
|
var relativePath: String { Self.relativePath(id: id, start: start) }
|
|
|
|
static func relativePath(id: String, start: Date) -> String {
|
|
let cal = Calendar(identifier: .gregorian)
|
|
let comps = cal.dateComponents([.year, .month], from: start)
|
|
let year = comps.year ?? 1970
|
|
let month = comps.month ?? 1
|
|
return String(format: "Workouts/%04d/%02d/%@.json", year, month, id)
|
|
}
|
|
}
|
|
|
|
extension WorkoutDocument {
|
|
/// Derive the aggregate `status` + `end` from the current logs. A log that is
|
|
/// `.completed` or `.skipped` counts as *resolved*; a workout whose logs are all
|
|
/// resolved is finished (`.completed`, with `end` stamped). This is the single
|
|
/// source of the status-from-logs rule — every screen that mutates logs calls it,
|
|
/// so an ended workout (remaining exercises skipped) stays finished no matter which
|
|
/// screen the next edit comes from.
|
|
mutating func recomputeStatusFromLogs() {
|
|
let statuses: [WorkoutStatus] = logs.map { WorkoutStatus(rawValue: $0.status) ?? .notStarted }
|
|
let isResolved: (WorkoutStatus) -> Bool = { $0 == .completed || $0 == .skipped }
|
|
|
|
let allResolved = !statuses.isEmpty && statuses.allSatisfy(isResolved)
|
|
let anyStarted = statuses.contains { $0 != .notStarted }
|
|
|
|
if allResolved {
|
|
status = WorkoutStatus.completed.rawValue
|
|
end = Date()
|
|
} else if anyStarted {
|
|
status = WorkoutStatus.inProgress.rawValue
|
|
end = nil
|
|
} else {
|
|
status = WorkoutStatus.notStarted.rawValue
|
|
end = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
struct WorkoutLogDocument: Codable, Sendable, Equatable, Identifiable {
|
|
var id: String // ULID
|
|
var exerciseName: String
|
|
var order: Int
|
|
var sets: Int
|
|
var reps: Int
|
|
var weight: Int
|
|
var loadType: Int
|
|
var durationSeconds: Int // total seconds (0 when not a timed exercise)
|
|
var currentStateIndex: Int
|
|
var completed: Bool
|
|
var status: String // WorkoutStatus raw value
|
|
var notes: String?
|
|
var date: Date
|
|
}
|
|
|
|
// MARK: - Forward-compatibility gate
|
|
|
|
/// A file whose `schemaVersion` exceeds the reader's `currentSchema` was written
|
|
/// by a newer app version; it must be quarantined, not partially decoded (Codable
|
|
/// silently drops unknown keys) and later rewritten — which would downgrade it
|
|
/// and lose the newer fields.
|
|
protocol VersionedDocument {
|
|
var schemaVersion: Int { get }
|
|
static var currentSchema: Int { get }
|
|
}
|
|
|
|
extension SplitDocument: VersionedDocument {}
|
|
extension WorkoutDocument: VersionedDocument {}
|
|
|
|
extension VersionedDocument {
|
|
/// True if this build can safely read (and rewrite) the document.
|
|
var isReadable: Bool { schemaVersion <= Self.currentSchema }
|
|
}
|
|
|
|
// MARK: - Soft-delete tombstone
|
|
|
|
/// Lives at `Stubs/<id>.json` and tells every device "this aggregate has been
|
|
/// deleted" — important when a remote device missed the `.removed` event for the
|
|
/// live file because it was offline. After `gracePeriod` any device can prune it.
|
|
struct Tombstone: Codable, Sendable, Equatable {
|
|
enum Kind: String, Codable, Sendable {
|
|
case split
|
|
case workout
|
|
}
|
|
|
|
var id: String // the aggregate's ULID
|
|
var kind: Kind
|
|
var deletedAt: Date
|
|
|
|
var relativePath: String { "Stubs/\(id).json" }
|
|
|
|
static let gracePeriod: TimeInterval = 30 * 24 * 60 * 60
|
|
}
|
|
|
|
// MARK: - JSON coding
|
|
|
|
/// Shared encoder/decoder. ISO-8601 dates and sorted keys for human-readable,
|
|
/// diff-friendly files when the container is browsed via the Files app.
|
|
enum DocumentCoder {
|
|
static let encoder: JSONEncoder = {
|
|
let e = JSONEncoder()
|
|
e.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
e.dateEncodingStrategy = .iso8601
|
|
return e
|
|
}()
|
|
|
|
static let decoder: JSONDecoder = {
|
|
let d = JSONDecoder()
|
|
d.dateDecodingStrategy = .iso8601
|
|
return d
|
|
}()
|
|
}
|