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
+215
View File
@@ -0,0 +1,215 @@
import Foundation
import Testing
import IndieSync
@testable import Workouts
/// Locks the per-set actuals capture invariant on `WorkoutLogDocument` the
/// `transition(to:)` fill/clear rules every status flip funnels through (list
/// checkbox, run-flow completeExercise/resetExercise, endKeepingProgress on both
/// platforms), the `fillSetEntries` plan-default shapes per `LoadType`, the
/// `effectiveSetEntries` legacy fallback every read path shares, and the
/// actuals-aware `WorkoutVolume`. Also pins that a v3 workout-log file (integer
/// weight, no `setEntries`) still decodes at today's Double/v4 shape.
struct SetEntryTests {
private static let date = Date(timeIntervalSince1970: 1_700_000_000)
private func log(
sets: Int = 3, reps: Int = 10, weight: Double = 100,
loadType: LoadType = .weight, durationSeconds: Int = 0,
status: WorkoutStatus = .notStarted, setEntries: [SetEntry]? = nil
) -> WorkoutLogDocument {
WorkoutLogDocument(
id: "LOG", exerciseName: "Ex", order: 0, sets: sets, reps: reps, weight: weight,
loadType: loadType.rawValue, durationSeconds: durationSeconds, currentStateIndex: 0,
status: status.rawValue, notes: nil, date: Self.date, setEntries: setEntries
)
}
// MARK: - transition(to:) fill/clear rules
/// Completing a log with nothing recorded (the list checkbox path) synthesizes
/// a full plan-default entry set.
@Test func completeFillsAllMissingEntries() {
var l = log(sets: 3, reps: 10, weight: 100)
l.transition(to: .completed)
#expect(l.setEntries?.count == 3)
#expect(l.setEntries?.allSatisfy { $0.reps == 10 && $0.weight == 100 && $0.seconds == nil } == true)
// The fill stamps the completion time on the synthesized entries.
#expect(l.setEntries?.allSatisfy { $0.completedAt == l.completedAt } == true)
}
/// Completing a log that already recorded (and adjusted) entries fills only the
/// missing tail the recorded ones are never overwritten.
@Test func completeFillsOnlyMissingTail() {
let recorded = SetEntry(reps: 8, weight: 90, completedAt: Self.date)
var l = log(sets: 3, reps: 10, weight: 100, setEntries: [recorded])
l.transition(to: .completed)
#expect(l.setEntries?.count == 3)
#expect(l.setEntries?.first == recorded) // preserved, not re-filled
#expect(l.setEntries?.last?.reps == 10) // tail comes from the plan
#expect(l.setEntries?.last?.weight == 100)
}
/// Re-completing an already-full log changes nothing (idempotent, like the
/// completedAt stamp).
@Test func completeIsIdempotentOnFullEntries() {
var l = log(sets: 2)
l.transition(to: .completed)
let first = l.setEntries
l.transition(to: .completed)
#expect(l.setEntries == first)
}
/// Reset to notStarted (swiping back to Ready, checkbox un-complete) wipes the
/// recorded entries with the timestamps the run never happened.
@Test func notStartedClearsEntries() {
var l = log(sets: 2)
l.transition(to: .completed)
l.transition(to: .notStarted)
#expect(l.setEntries == nil)
}
/// Skipping keeps partial entries "End Workout, keeping progress" must not
/// discard the sets that were done.
@Test func skippedPreservesPartialEntries() {
let partial = [SetEntry(reps: 10, weight: 100, completedAt: Self.date)]
var l = log(sets: 3, status: .inProgress, setEntries: partial)
l.transition(to: .skipped)
#expect(l.setEntries == partial)
}
/// Moving to inProgress leaves entries alone (the run flow appends explicitly).
@Test func inProgressLeavesEntriesAlone() {
let partial = [SetEntry(reps: 10, weight: 100, completedAt: Self.date)]
var l = log(sets: 3, status: .inProgress, setEntries: partial)
l.transition(to: .inProgress)
#expect(l.setEntries == partial)
}
// MARK: - fillSetEntries per LoadType
@Test func fillShapesWeightedEntries() {
var l = log(reps: 12, weight: 42.5, loadType: .weight)
l.fillSetEntries(upTo: 1, at: Self.date)
#expect(l.setEntries == [SetEntry(reps: 12, weight: 42.5, completedAt: Self.date)])
}
@Test func fillShapesBodyweightEntries() {
var l = log(reps: 15, loadType: .none)
l.fillSetEntries(upTo: 1, at: Self.date)
#expect(l.setEntries == [SetEntry(reps: 15, completedAt: Self.date)])
}
@Test func fillShapesDurationEntries() {
var l = log(reps: 0, loadType: .duration, durationSeconds: 45)
l.fillSetEntries(upTo: 1, at: Self.date)
#expect(l.setEntries == [SetEntry(seconds: 45, completedAt: Self.date)])
}
/// A fill to zero (or below the recorded count) never turns nil into [] and
/// never truncates entries are append-only.
@Test func fillNeverShrinksOrMaterializesEmpty() {
var untouched = log()
untouched.fillSetEntries(upTo: 0, at: Self.date)
#expect(untouched.setEntries == nil)
let recorded = [SetEntry(reps: 8, weight: 90, completedAt: Self.date),
SetEntry(reps: 6, weight: 90, completedAt: Self.date)]
var full = log(setEntries: recorded)
full.fillSetEntries(upTo: 1, at: Self.date)
#expect(full.setEntries == recorded)
}
// MARK: - effectiveSetEntries fallback
/// Recorded entries win verbatim.
@Test func effectiveEntriesPreferRecorded() {
let recorded = [SetEntry(reps: 8, weight: 90, completedAt: Self.date)]
let l = log(status: .completed, setEntries: recorded)
#expect(l.effectiveSetEntries == recorded)
}
/// A completed legacy log (no entries on disk) synthesizes plan-default entries.
@Test func effectiveEntriesSynthesizeForCompletedLegacy() {
var l = log(sets: 3, reps: 10, weight: 100, status: .completed)
l.completedAt = Self.date
let entries = l.effectiveSetEntries
#expect(entries.count == 3)
#expect(entries.allSatisfy { $0.reps == 10 && $0.weight == 100 && $0.completedAt == Self.date })
}
/// An unfinished log with nothing recorded performed nothing.
@Test func effectiveEntriesEmptyForUnfinishedLegacy() {
#expect(log(status: .notStarted).effectiveSetEntries.isEmpty)
#expect(log(status: .inProgress).effectiveSetEntries.isEmpty)
#expect(log(status: .skipped).effectiveSetEntries.isEmpty)
}
// MARK: - WorkoutVolume from actuals
/// Recorded actuals drive the sum: reps×weight per entry, not the plan.
@Test func volumeSumsActualEntries() {
let l = log(sets: 3, reps: 10, weight: 100, status: .completed, setEntries: [
SetEntry(reps: 10, weight: 100, completedAt: Self.date), // 1000
SetEntry(reps: 8, weight: 102.5, completedAt: Self.date), // 820
])
#expect(WorkoutVolume.total([l]) == 1820)
}
/// A completed legacy log (no entries) matches the old sets×reps×weight formula,
/// and mixed actual/legacy logs sum both correctly.
@Test func volumeLegacyEqualsOldFormulaAndMixes() {
let legacyCompleted = log(sets: 4, reps: 10, weight: 135, status: .completed) // 5400
#expect(WorkoutVolume.total([legacyCompleted]) == 5400)
// Legacy logs that never completed also keep the old full-plan behavior.
let legacySkipped = log(sets: 3, reps: 8, weight: 100, status: .skipped) // 2400
#expect(WorkoutVolume.total([legacySkipped]) == 2400)
let actual = log(sets: 2, reps: 10, weight: 50, status: .completed, setEntries: [
SetEntry(reps: 10, weight: 50, completedAt: Self.date), // 500
])
#expect(WorkoutVolume.total([legacyCompleted, legacySkipped, actual]) == 8300)
}
/// Non-weight logs contribute nothing, with or without entries.
@Test func volumeIgnoresUnweightedLogs() {
var timed = log(loadType: .duration, durationSeconds: 45, status: .completed)
timed.transition(to: .completed)
let bodyweight = log(reps: 12, loadType: .none, status: .completed)
#expect(WorkoutVolume.total([timed, bodyweight]) == 0)
}
// MARK: - Legacy v3 decode
/// A workout-log shape from a v3 file integer `weight`, no `setEntries`, no
/// per-log `updatedAt` decodes at today's shape: the Int lands in the Double
/// natively and both new optionals stay nil.
@Test func decodesLegacyV3WorkoutLog() throws {
let json = """
{
"id": "01HZZZZZZZZZZZZZZZZZZZZZZW",
"exerciseName": "Bench Press",
"order": 0,
"sets": 4,
"reps": 10,
"weight": 135,
"loadType": 1,
"durationSeconds": 0,
"currentStateIndex": 4,
"status": "completed",
"date": "2023-11-14T22:13:20Z"
}
"""
let decoded = try DocumentCoder.decode(WorkoutLogDocument.self, from: Data(json.utf8))
#expect(decoded.weight == 135)
#expect(decoded.setEntries == nil)
#expect(decoded.updatedAt == nil)
// And a fractional weight round-trips through the coder.
var fractional = decoded
fractional.weight = 42.5
let re = try DocumentCoder.decode(WorkoutLogDocument.self, from: DocumentCoder.encode(fractional))
#expect(re.weight == 42.5)
}
}