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
@@ -41,7 +41,9 @@ struct ExerciseAddEditView: View {
self.exercise = exercise
self.split = split
let w = exercise.weight
// The tens/ones pickers step whole values; a fractional stored weight
// shows (and saves back) its whole part until UX #3's UI lands.
let w = Int(exercise.weight)
_exerciseName = State(initialValue: exercise.name)
_loadType = State(initialValue: exercise.loadTypeEnum)
_minutes = State(initialValue: exercise.durationMinutes)
@@ -200,7 +202,7 @@ struct ExerciseAddEditView: View {
doc.exercises[idx].name = exerciseName
doc.exercises[idx].sets = sets
doc.exercises[idx].reps = reps
doc.exercises[idx].weight = newWeight
doc.exercises[idx].weight = Double(newWeight)
doc.exercises[idx].loadType = loadType.rawValue
doc.exercises[idx].durationSeconds = durationSecs
// ON a (possibly empty) array; OFF nil, discarding any prior settings.
@@ -28,6 +28,7 @@ import UIKit
/// teal for rest, with the current phase drawn as a wider dash.
struct ExerciseProgressView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.verticalSizeClass) private var verticalSizeClass
/// The shared working workout document owned by the parent list. We mutate the
/// matching log in place and ask the parent to persist each change driving the UI
@@ -66,6 +67,12 @@ struct ExerciseProgressView: View {
/// end anchor the watch's mirror counts off.
@AppStorage("doneCountdownSeconds") private var doneCountdownSeconds: Int = 5
/// Display unit for the adjust affordance (stored weights stay unit-less).
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
/// Presents the compact adjust sheet for the just-completed set's entry.
@State private var showingAdjust = false
/// Planned set count for this run. `One More` bumps it (and the log's `sets`).
@State private var setCount: Int
@State private var currentPage: Int
@@ -204,18 +211,28 @@ struct ExerciseProgressView: View {
return detail.isEmpty ? setsText : "\(setsText) × \(detail)"
}
/// The half-and-half split: top/bottom in portrait, side-by-side in landscape
/// (compact vertical size class), so neither half ends up squeezed into a
/// short rotated band. `AnyLayout` preserves view identity across rotation
/// the paged flow's position and running timers survive the swap.
private var splitLayout: AnyLayout {
verticalSizeClass == .compact
? AnyLayout(HStackLayout(spacing: 0))
: AnyLayout(VStackLayout(spacing: 0))
}
var body: some View {
if startsCompleted {
// Same top/bottom split as the run flow, with the Completed badge where
// Same half-and-half split as the run flow, with the Completed badge where
// the paged flow would be, so the form-guide figure stays on screen.
VStack(spacing: 0) {
splitLayout {
CompletedPhaseView(log: log)
ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "")
}
.navigationTitle(log?.exerciseName ?? "")
.navigationBarTitleDisplayMode(.inline)
} else if startsSkipped {
VStack(spacing: 0) {
splitLayout {
SkippedPhaseView()
ExerciseFigureSlot(exerciseName: log?.exerciseName ?? "")
}
@@ -227,7 +244,7 @@ struct ExerciseProgressView: View {
}
private var flowBody: some View {
VStack(spacing: 0) {
splitLayout {
// Paged flow top half.
TabView(selection: $currentPage) {
ForEach(0..<totalPages, id: \.self) { index in
@@ -443,12 +460,15 @@ struct ExerciseProgressView: View {
let cycleIndex = index - base
if cycleIndex == cycleCount {
// Finish page confirm Done (auto-fires) or add One More.
FinishPhaseView(
isActive: isActive,
onDone: { completeExercise(); dismiss() },
onOneMore: addSet,
anchorEnd: anchorEnd
)
VStack(spacing: 0) {
FinishPhaseView(
isActive: isActive,
onDone: { completeExercise(); dismiss() },
onOneMore: addSet,
anchorEnd: anchorEnd
)
adjustPill
}
} else if cycleIndex.isMultiple(of: 2) {
let setNumber = cycleIndex / 2 + 1
if isDuration {
@@ -476,20 +496,75 @@ struct ExerciseProgressView: View {
}
} else {
// Rest phase. Auto-advances to the next work page when the timer hits zero.
CountdownPhaseView(
header: "Rest",
tint: .restTimer,
seconds: restSeconds,
isActive: isActive,
anchorStart: anchorStart,
anchorEnd: anchorEnd
) {
withAnimation { advance(from: index) }
VStack(spacing: 0) {
CountdownPhaseView(
header: "Rest",
tint: .restTimer,
seconds: restSeconds,
isActive: isActive,
anchorStart: anchorStart,
anchorEnd: anchorEnd
) {
withAnimation { advance(from: index) }
}
adjustPill
}
}
}
}
// MARK: - Adjust the last set's entry
/// The just-completed set's recorded entry, shown on the rest and finish pages
/// as a tappable pill the log-by-default record with a correct-by-exception
/// affordance. Dead time by design: it never blocks the countdown or auto-advance.
@ViewBuilder
private var adjustPill: some View {
if let entry = log?.setEntries?.last {
Button {
showingAdjust = true
} label: {
HStack(spacing: 6) {
Text(SetEntryFormat.summary(entry, weightUnit: weightUnit))
Image(systemName: "pencil")
.font(.caption)
}
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(.quaternary.opacity(0.5), in: Capsule())
}
.buttonStyle(.plain)
.padding(.bottom, 32) // clear the phase-dot row overlaid at the bottom
.sheet(isPresented: $showingAdjust) { adjustSheet }
}
}
@ViewBuilder
private var adjustSheet: some View {
if let log, let entry = log.setEntries?.last {
SetEntryAdjustSheet(
entry: entry,
setNumber: log.setEntries?.count ?? 1,
loadType: LoadType(rawValue: log.loadType) ?? .weight,
weightUnit: weightUnit,
onChange: updateLastEntry
)
.presentationDetents([.height(300)])
.presentationDragIndicator(.visible)
}
}
/// Write an adjusted value back onto the last recorded entry and persist.
private func updateLastEntry(_ entry: SetEntry) {
guard let i = doc.logs.firstIndex(where: { $0.id == logID }),
let last = doc.logs[i].setEntries?.indices.last else { return }
doc.logs[i].setEntries?[last] = entry
doc.updatedAt = Date()
onChange()
}
// MARK: - Mutations
/// Leave the Ready page for the first work phase, marking the exercise started.
@@ -525,6 +600,9 @@ struct ExerciseProgressView: View {
if let i = doc.logs.firstIndex(where: { $0.id == logID }) {
doc.logs[i].sets = newCount
doc.logs[i].currentStateIndex = newCount - 1 // every prior set is now complete
// The old final set just became a completed *prior* set record its entry.
// The bonus set's own entry lands when it completes, not now.
doc.logs[i].fillSetEntries(upTo: newCount - 1, at: Date())
doc.logs[i].transition(to: .inProgress)
recomputeWorkoutStatus()
doc.updatedAt = Date()
@@ -552,6 +630,9 @@ struct ExerciseProgressView: View {
guard reached > doc.logs[i].currentStateIndex else { return }
doc.logs[i].currentStateIndex = reached
// Log-by-default: each newly-completed set gets a plan-filled entry
// (append-only swiping back never removes one; adjust corrects the last).
doc.logs[i].fillSetEntries(upTo: reached, at: Date())
doc.logs[i].transition(to: .inProgress)
recomputeWorkoutStatus()
@@ -589,7 +670,7 @@ struct ExerciseProgressView: View {
// MARK: - Formatting
static func durationLabel(_ seconds: Int) -> String {
nonisolated static func durationLabel(_ seconds: Int) -> String {
let mins = seconds / 60
let secs = seconds % 60
if mins > 0 && secs > 0 { return "\(mins)m \(secs)s" }
@@ -598,6 +679,116 @@ struct ExerciseProgressView: View {
}
}
// MARK: - Set entry formatting
/// Renders recorded `SetEntry` actuals for display (the pill, the Completed page).
/// Which fields an entry carries follows the log's `LoadType`, so formatting keys
/// off the populated fields rather than re-deriving the type.
enum SetEntryFormat {
/// One entry: "10 reps · 45 lb", "10 reps", or "45 sec".
static func summary(_ entry: SetEntry, weightUnit: WeightUnit) -> String {
var parts: [String] = []
if let reps = entry.reps { parts.append("\(reps) reps") }
if let weight = entry.weight { parts.append(weightUnit.format(weight)) }
if let seconds = entry.seconds { parts.append(ExerciseProgressView.durationLabel(seconds)) }
return parts.joined(separator: " · ")
}
/// All of a log's entries on one line: "10 · 8 · 6 reps @ 45 lb" when the
/// weight is uniform, a per-set "10 × 45 lb · 8 × 42.5 lb" list when it varies,
/// "45 · 45 · 30 sec" for durations, "10 · 8 · 6 reps" for bodyweight.
static func actualsLine(_ entries: [SetEntry], weightUnit: WeightUnit) -> String {
guard !entries.isEmpty else { return "" }
if entries.contains(where: { $0.seconds != nil }) {
return entries.map { "\($0.seconds ?? 0)" }.joined(separator: " · ") + " sec"
}
let repsList = entries.map { "\($0.reps ?? 0)" }.joined(separator: " · ")
let weights = entries.compactMap(\.weight)
guard !weights.isEmpty else { return repsList + " reps" }
if Set(weights).count == 1 {
return "\(repsList) reps @ \(weightUnit.format(weights[0]))"
}
return entries.map { entry in
guard let weight = entry.weight else { return "\(entry.reps ?? 0)" }
return "\(entry.reps ?? 0) × \(weightUnit.format(weight))"
}.joined(separator: " · ")
}
}
// MARK: - Set entry adjust sheet
/// Compact steppers for correcting the just-completed set's entry reps and
/// weight for a weighted set, reps alone for bodyweight, seconds for a timed
/// hold. Changes apply live (via `onChange`), so a swipe-down needs no confirm.
private struct SetEntryAdjustSheet: View {
let setNumber: Int
let loadType: LoadType
let weightUnit: WeightUnit
let onChange: (SetEntry) -> Void
@State private var entry: SetEntry
init(entry: SetEntry, setNumber: Int, loadType: LoadType, weightUnit: WeightUnit,
onChange: @escaping (SetEntry) -> Void) {
_entry = State(initialValue: entry)
self.setNumber = setNumber
self.loadType = loadType
self.weightUnit = weightUnit
self.onChange = onChange
}
/// Plate-math step in the *display* unit (the stored value stays unit-less).
private var weightStep: Double { weightUnit == .kg ? 1.25 : 2.5 }
var body: some View {
VStack(spacing: 24) {
Text("Set \(setNumber)")
.font(.headline)
.foregroundStyle(.secondary)
switch loadType {
case .weight:
repsStepper
weightStepper
case .none:
repsStepper
case .duration:
secondsStepper
}
}
.padding(28)
.frame(maxHeight: .infinity, alignment: .top)
.onChange(of: entry) { _, updated in onChange(updated) }
}
private var repsStepper: some View {
Stepper(value: Binding(get: { entry.reps ?? 0 }, set: { entry.reps = $0 }),
in: 0...200) {
Text("\(entry.reps ?? 0) reps")
.font(.title3.weight(.semibold))
.monospacedDigit()
}
}
private var weightStepper: some View {
Stepper(value: Binding(get: { entry.weight ?? 0 }, set: { entry.weight = $0 }),
in: 0...2000, step: weightStep) {
Text(weightUnit.format(entry.weight ?? 0))
.font(.title3.weight(.semibold))
.monospacedDigit()
}
}
private var secondsStepper: some View {
Stepper(value: Binding(get: { entry.seconds ?? 0 }, set: { entry.seconds = $0 }),
in: 0...3600, step: 5) {
Text(ExerciseProgressView.durationLabel(entry.seconds ?? 0))
.font(.title3.weight(.semibold))
.monospacedDigit()
}
}
}
// MARK: - Haptics
/// Maps the watch flow's haptic vocabulary onto UIKit feedback generators so the iPhone
@@ -750,6 +941,8 @@ private extension View {
private struct CompletedPhaseView: View {
let log: WorkoutLogDocument?
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
/// Scales the completion badge with Dynamic Type.
@ScaledMetric(relativeTo: .largeTitle) private var badgeSize: CGFloat = 96
@@ -785,7 +978,13 @@ private struct CompletedPhaseView: View {
if let completedAt = log.completedAt {
Text(completedAt.formattedRelativeDateTime())
}
Text(planSummary)
// Recorded actuals ("10 · 8 · 6 reps @ 45 lb") beat the plan
// summary; legacy logs without entries keep showing the plan.
if let entries = log.setEntries, !entries.isEmpty {
Text(SetEntryFormat.actualsLine(entries, weightUnit: weightUnit))
} else {
Text(planSummary)
}
if let elapsed {
Text("in \(elapsed)")
}
@@ -136,7 +136,9 @@ struct PlanEditView: View {
if let log = WorkoutDocument(from: workout).logs.first(where: { $0.id == logID }) {
sets = log.sets
reps = log.reps
weight = log.weight
// The picker steps whole values; a fractional stored weight
// shows (and saves back) its whole part until UX #3's UI lands.
weight = Int(log.weight)
durationMinutes = log.durationSeconds / 60
durationSeconds = log.durationSeconds % 60
selectedLoadType = LoadType(rawValue: log.loadType) ?? .weight
@@ -153,7 +155,7 @@ struct PlanEditView: View {
guard let i = doc.logs.firstIndex(where: { $0.id == logID }) else { return }
doc.logs[i].sets = sets
doc.logs[i].reps = reps
doc.logs[i].weight = weight
doc.logs[i].weight = Double(weight)
doc.logs[i].durationSeconds = totalSeconds
doc.logs[i].loadType = selectedLoadType.rawValue
doc.updatedAt = Date()
@@ -170,7 +172,7 @@ struct PlanEditView: View {
if let ei = sDoc.exercises.firstIndex(where: { $0.name == exerciseName }) {
sDoc.exercises[ei].sets = sets
sDoc.exercises[ei].reps = reps
sDoc.exercises[ei].weight = weight
sDoc.exercises[ei].weight = Double(weight)
sDoc.exercises[ei].durationSeconds = totalSeconds
sDoc.exercises[ei].loadType = selectedLoadType.rawValue
sDoc.updatedAt = Date()
@@ -34,7 +34,12 @@ struct WeightProgressionChartView: View {
@AppStorage("weightUnit") private var weightUnit: WeightUnit = .lb
private var weightData: [WeightDataPoint] {
logs.map { WeightDataPoint(date: $0.date, weight: $0.weight) }
// Top-set *actual* weight per completed log; legacy logs without recorded
// entries fall back to the planned weight (the old behavior).
logs.map { log in
let topSet = log.setEntries?.compactMap(\.weight).max()
return WeightDataPoint(date: log.date, weight: topSet ?? log.weight)
}
}
var body: some View {
@@ -100,7 +105,7 @@ struct WeightProgressionChartView: View {
if weightDifference > 0 {
let percentIncrease = firstWeight > 0
? Int((Double(weightDifference) / Double(firstWeight)) * 100)
? Int((weightDifference / firstWeight) * 100)
: 0
if percentIncrease >= 20 {
return "Amazing progress! You've increased your weight by \(weightUnit.format(weightDifference)) (\(percentIncrease)%)!"
@@ -121,5 +126,5 @@ struct WeightProgressionChartView: View {
struct WeightDataPoint: Identifiable {
let id = UUID()
let date: Date
let weight: Int
let weight: Double
}