Record per-set actuals and drive the chart and volume from them

Every completed set now writes a SetEntry (reps/weight or seconds),
pre-filled from the plan by transition(to:) so the list checkbox, both
run flows, and One More all capture for free; reset clears, skip keeps
partials. The rest and finish pages show the just-done set as a pill
that opens a stepper sheet for correcting reps and weight (2.5 lb /
1.25 kg steps). The Weight Progression chart plots the top-set actual
weight and workout volume sums recorded sets, both falling back to the
plan for legacy logs via effectiveSetEntries.

Storage side of UX #3 rides along: plan weights are Double now.
Schema bumps: SplitDocument 2→3, WorkoutDocument 3→4 (a fractional
weight fails an older Int decode, and a rewrite would strip the
irreplaceable actuals), SwiftData cache 4→5. A per-log updatedAt is
reserved for the future cross-device log merge.

Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
2026-07-08 12:48:37 -04:00
parent c05e83cff7
commit 394ec0989e
24 changed files with 1193 additions and 77 deletions
@@ -151,6 +151,30 @@ struct WatchCacheApplierTests {
#expect(workoutIDs(in: ctx) == ["01WKA"])
}
// MARK: - New-field fidelity through the watch cache
/// A pushed workout carrying the v4 log fields fractional weight, recorded
/// `setEntries`, per-log `updatedAt` survives apply cache entity document
/// intact, so the watch's echo back to the phone can't strip actuals.
@Test func applyRoundTripsSetEntriesAndPerLogUpdatedAt() throws {
let ctx = try makeContext()
var doc = workout(id: "01WKENTRIES")
doc.logs[0].weight = 42.5
doc.logs[0].status = WorkoutStatus.completed.rawValue
doc.logs[0].setEntries = [
SetEntry(reps: 10, weight: 42.5, completedAt: Self.ts),
SetEntry(reps: 8, weight: 45, completedAt: Self.ts),
]
doc.logs[0].updatedAt = Self.ts
WatchCacheApplier.apply(splits: [], workouts: [doc], into: ctx)
let entity = try #require(CacheMapper.fetchWorkout(id: "01WKENTRIES", in: ctx))
let rebuilt = WorkoutDocument(from: entity)
#expect(rebuilt.logs[0].weight == 42.5)
#expect(rebuilt.logs[0].setEntries == doc.logs[0].setEntries)
#expect(rebuilt.logs[0].updatedAt == Self.ts)
}
@Test func optionalOverloadReturnsTrueWhenBothSetsDecode() throws {
let ctx = try makeContext()
// Typed optionals to disambiguate onto the nil-aware overload (an authoritative,
@@ -34,7 +34,7 @@ struct WatchHelpersTests {
private static let ts = Date(timeIntervalSince1970: 1_700_000_000)
private func log(id: String, sets: Int, reps: Int, weight: Int, loadType: LoadType) -> WorkoutLogDocument {
private func log(id: String, sets: Int, reps: Int, weight: Double, loadType: LoadType) -> WorkoutLogDocument {
WorkoutLogDocument(
id: id, exerciseName: "Ex-\(id)", order: 0, sets: sets, reps: reps, weight: weight,
loadType: loadType.rawValue, durationSeconds: 0, currentStateIndex: 0,