Files
rzen 7400094eda End the watch session on Discard, plus start-flow UX tweaks
Watch-side follow-through for the End Workout flow:
- The phone now pushes an authoritative set (in-progress, not-started, and
  completed within 24h) instead of the 25 most-recent workouts, and the watch
  prunes any workout absent from it. So a Discard/Delete (or a completed run aging
  out) drops off the watch, empties its active list, and ends the HKWorkoutSession
  — fixing the persistent wrist-raise re-foregrounding. The watch never originates
  a workout, so pruning can't lose local data; the 24h grace keeps a just-finished
  run on screen. The gate pops if the run you're viewing is pruned.

UX tweaks:
- The in-workout ⋯ is now a pull-down Menu (Add Exercise / End Workout) rather than
  an action sheet.
- Starting a split while another workout is still active now prompts to end the
  current one(s) — keeping their progress — or run in parallel. Wired into both
  start paths (the split picker and "Start This Split"), via a shared
  WorkoutDocument.endKeepingProgress() helper.

Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
2026-06-22 21:30:06 -04:00

185 lines
6.3 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 iPhoneWatch 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
}
}
/// 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.
mutating func endKeepingProgress() {
for i in logs.indices where (WorkoutStatus(rawValue: logs[i].status) ?? .notStarted) != .completed {
logs[i].status = WorkoutStatus.skipped.rawValue
logs[i].completed = false
}
recomputeStatusFromLogs()
updatedAt = Date()
}
}
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
}()
}