Back/forward chevrons step the board a day at a time around the
existing calendar button, and a Today button — shown only when the
board is off today — jumps back. Also add the missing changelog
entries for the Today board itself.
Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
The count-in now speaks the exercise name at 5s left, leaves the 4s
beat silent so a long name can finish, then counts down — "in three,
two, one, Go!" (it previously counted up from 4s). Timed work sets
get a mirrored count-out: "<Exercise> ends." at 5s, the same buffer
and countdown, and silence at zero where the rest buzz marks the
boundary; the final "one" releases the audio duck.
Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
The UX redesign's first landing (spec in UX-REDESIGN.md): ContentView
becomes a Today / Progress / Settings TabView, "Routine" replaces
"Split" in every user-facing string and view name (code-level types
keep their names), and workout starting moves to shared
WorkoutStarter / StartedWorkoutNavigator plumbing.
- New Progress tab: weekly goal streaks, workout trends, per-exercise
weight progression, achievements, and the full history list
(WorkoutLogsView -> WorkoutHistoryView).
- Goals: stable categories workouts roll up to, managed from Settings.
- New Meditation exercise + starter routine; timed sits record to
Apple Health as Mind & Body sessions.
Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
TestFlight 2.3 (125) "crashed when watch ended an exercise": the
isDeleted guard from 85e1582 only covers the delete→save window. Once
the deletion is saved the model unregisters — isDeleted reads false
again, modelContext goes nil, and any persisted-property read still
traps (_InitialBackingData.getValue). StartedWorkoutNavigator retained
the run's @Model in @State for the whole workout, so an observer
remove/re-add churn (e.g. iCloud reachability flapping) invalidated it
underneath the pushed screen, and the watch's completion push triggered
the rebuild that read it.
Two layers: the fromLive/SplitDetailView guards now also require a
non-nil modelContext, and StartedWorkoutNavigator pushes a plain id
route, re-fetching the entity fresh on every destination build — a
re-imported run resolves to its live instance; a gone run shows a
placeholder instead of trapping.
Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
Variant A refuted on device 2026-07-10 (phone kept its higher count; the
win was timing, not a structural guard — the merge remains pure
newest-modTime-wins). Step 2 now lists ranked link-severing methods,
since phone-side Airplane Mode provably doesn't cut the watch link.
Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
A closure-based NavigationLink builds its destination view eagerly for
every row on every parent-list body evaluation — including the view-graph
update SwiftUI runs the instant SyncEngine.delete does context.delete +
save. Mapping the row's just-deleted @Model to a document there reads a
persisted property on a dead model, which traps in SwiftData
(_InitialBackingData.getValue -> assertionFailure). This is the "Crashed
when deleted a workout" TestFlight report on 2.3 (124).
Add WorkoutDocument(fromLive:) (nil when isDeleted) + a .deletedPlaceholder
and route every destination-init / updatedAt-absorb map through it:
phone WorkoutLogListView + SplitDetailView (read split.id) and watch
WorkoutLogListView. Fixes the whole crash class, not just the report.
Claude-Session: https://claude.ai/code/session_01BQcEWmAPA78338QuEwRkAh
The run flow's between-exercise rest now previews the next exercise — its
figure and a large "Coming up" name — and the hands-free narration gains a
per-second count-in ("<Exercise>, in 1, 2, 3, GO!") spoken before every set,
plus a "Coming up" announcement as a between-exercise rest begins. A new
Settings > Narration picker chooses the count-in cues, the setup/form read,
or both.
Also fixes spoken cues going silent after the first exercise in a flow split:
the stop-on-teardown moved from the per-exercise view (rebuilt on every
hand-off) to the run host, which stays mounted for the whole run. The audio
session now holds its duck across the per-second count so background music
doesn't pulse between words.
Claude-Session: https://claude.ai/code/session_01BQcEWmAPA78338QuEwRkAh
The rollback repro (watch-side airplane mode, phone-ahead/watch-behind
divergence, queued transferUserInfo delivery), the ready-made L4 Merge
Test split in the iCloud container, confirm/refute criteria, and the
deferral caveats (monotonic clamp would break the deliberate
swipe-back-to-Ready reset).
Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
The decode-failure freeze (BULLETPROOFING.md L3) is production-reachable
after all - a phone app can update days before the watch app
auto-updates, and every push in between fails to decode, silently
freezing the watch at its last good sync. The bridge now tracks
schemaMismatch (set on a failed decode, cleared on the next good apply)
and ActiveWorkoutGateView shows an "Update both apps to resume sync"
banner while it's set.
Also give requestSync an error handler that logs the dropped pull (L2;
deliberately no retry - the activation/reachability edges re-pull), and
sync DEVICE-COMMUNICATION.md to the post-bulletproofing reality (T1 as
optimization, session recovery, degraded pushes, surfaced mismatch).
Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
Two stale-UI traps in the run flow (BULLETPROOFING.md M2, M3):
A log resolved remotely (completed or skipped on the other device) left
the open exercise screen live - its next recorded set wrote the log
back to in-progress, resurrecting it through the per-log merge. Both
platforms' ExerciseProgressView now observe the log's status and
dismiss on a remote terminal flip (a locallyResolved flag exempts the
screen's own Done / flow hand-off). The watch also gains the phone's
startsSkipped terminal page, so opening a skipped exercise shows a
static badge instead of a live flow.
The live-mirror cover ran the flow engine with no onAdvance host, so an
auto-advance split's terminal between-exercise rest completed the
exercise then froze at 0:00. With no hand-off host it now dismisses
instead; the driver's next-exercise frame re-presents the cover.
Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
Two watch-stranding fixes (BULLETPROOFING.md M1, M4):
The exclusive-edit lock rode in the latest-wins context and was cleared
only by onDisappear, so an editor left open in a pocketed (or
force-quit) phone parked the watch's run indefinitely ("Editing on
iPhone..."). The scene-phase hook now publishes the locks as cleared
while the app is backgrounded - without forgetting them locally - and
re-asserts them on return to the foreground.
pushAll treated a failed updateApplicationContext as log-only, so a
payload past WatchConnectivity's size ceiling silently froze the watch
out of all future state. A failed push now retries with the
recently-completed tail dropped (display-only on the watch); only a
failure of the slim push too remains an error.
Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
The HKWorkoutSession could only ever be born via the phone's one-shot
startWatchApp handoff (BULLETPROOFING.md H1-H3): a dropped handoff, a
watch crash/reboot, or a run engaged manually on the wrist left the
whole workout sessionless - no heart rate, no Health save, app
suspending wrist-down - and "End Current & Start New" swallowed the
handoff against the old session's idempotency guard.
Now the coordinator self-starts a session on any reconcile that finds
an active run with none running (SessionEndPlanner.shouldStart /
runToStart, activity type from the run's split), recover() re-adopts a
crash-orphaned session at launch, and a system-ended session salvages
its Health save instead of dropping it. A .finish decided for a
session younger than 30s demotes to .discard so the stale-context
races can't save junk workouts attributed to the wrong run; parallel
completions now pick the survivor deterministically.
Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
DEVICE-COMMUNICATION.md: exhaustive reference for every phone<->watch
exchange - triggers, wire datapoints, transports (T1-T4), and the internal
structures each side mutates - plus end-to-end sequences and failure modes.
BULLETPROOFING.md: ranked gap analysis (H1-H3 session lifecycle, M1-M4
stranding/stale-UI traps, L-tier) with code evidence, failure scenarios,
fix directions, and a recommended fix order.
Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
ExerciseListView is reached only via Settings > Splits > split > Edit >
Exercises, so its toolbar start button was effectively undiscoverable and
duplicated the home-screen split picker (the sole remaining start path).
Strip the button and its now-dead start flow: the active-workout prompt,
end-and-restart handling, started-workout navigation, and the AppServices
dependency. Also drop the changelog entry for the watch-launch wiring of
that button (f01e149) since the button no longer exists and never shipped.
Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
A single "Cardio" library entry (running-in-place figure, duration-logged)
plus a matching Cardio starter split tagged .cardio (HealthKit .mixedCardio),
so the Apple Watch records the real aerobic workout while the phone logs the
time — no per-machine cardio entries or schema changes.
Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
Adds WorkoutActivityType.warmUp (HealthKit .preparationAndRecovery) and
.stretching (.flexibility), and retags the six starter splits that were all
mislabeled as Functional Strength:
- Warm-Up: Upper Body Warm-Up, Lower Body Warm-Up, Morning Wake-Up
- Stretching: Morning Mobility, Full Body Stretch, Evening Stretch
The split editor's activity picker surfaces them automatically (CaseIterable).
Older app versions decode the new raw values as the default type — additive and
not schema-gated, so no quarantine.
The "Start This Split" button on a split's exercise list minted and
saved the workout but never called WorkoutLauncher, so the Apple Watch
never came up when starting from there — only the home-screen split
picker launched it. Inject AppServices into ExerciseListView and call
launchWatchWorkout from start(), mirroring the picker path.
Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
Premium/enhanced voices ship with the tier in their name (e.g. "Ava
(Premium)"), so appending "— Premium" produced "Ava (Premium) — Premium"
in the voice picker. Move the label logic to SpeechSettings.displayLabel
and skip the suffix when the name already contains the tier (still
name-only for default voices). Deterministic string helper, unit-tested.
Claude-Session: https://claude.ai/code/session_01BQcEWmAPA78338QuEwRkAh
Two per-split settings, with the global Settings values as defaults:
- restSeconds: Int? — per-split rest, used between sets and (in flow) between
exercises; nil falls back to the global default.
- autoAdvance: Bool? — flow mode: finishing an exercise rests, then opens the
next one hands-free, all the way through the split.
Both are optional, snapshotted onto WorkoutDocument at the start sites (no live
split link), and not schema-bumped — same degradation pattern as activityType.
A thin RunFlowView wrapper (iOS + watch) owns the on-screen log and swaps it via
.id(currentLogID) on hand-off, so the per-exercise ExerciseProgressView stays
per-logID and untouched; the between-exercise rest reuses the existing .rest
countdown as the terminal page. The mirror reuses the per-logID live channel:
the wrapper suppresses the boundary .ended teardown so it follows across
exercises, and ContentView re-keys the cover on frame.logID — no sync-bridge
changes.
Morning Wake-Up ships as a flowing 45s-work / 15s-rest routine.
New Rest & Pacing section in the split editor exposes both controls.
Speak exercise setup and form cues aloud with AVSpeechSynthesizer:
- Library detail: a speaker toolbar button reads the full reference aloud.
- Active workout: an opt-in "Speak Exercise Cues" setting speaks a brief
cue (Setup/Execution/Cues) when an exercise starts, hands-free.
- Settings › Voice: pick the voice (auto-prefers an installed enhanced/
premium English voice), a premium-download nudge shown only while on a
basic voice, and a Speed/Pitch/Volume sheet with Reset to Defaults.
On-device and offline; ducks other audio rather than stopping it. iPhone
only for now. Shared SpeechSettings is the single source of truth for the
voice/prosody, read fresh per utterance so changes preview live.
Claude-Session: https://claude.ai/code/session_01BQcEWmAPA78338QuEwRkAh
ingestFromWatch arbitrated by whole-document updatedAt, so concurrent edits
to the same workout on both devices (phone edits exercise A while the watch
completes exercise B) lost one side wholesale — the newer snapshot replaced
the other (H1).
Reconcile per log instead. WorkoutMergePlanner (pure, deterministic) unions
logs by id, resolves each by newest per-log updatedAt, and applies
phone-authored deletion tombstones so an absent log is never ambiguous
between "deleted on the phone" and "just added on the watch". Edits to
different exercises now commute — delivery order and offline gaps stop
mattering. A stale/duplicate push merges back to exactly the cached doc, so
ingest re-pushes authoritative state rather than writing.
The per-log updatedAt scaffolding shipped (unused) in schema v4; it's now
stamped by transition(to:) on status flips and a new touch() at the
content-only edit sites (order, notes, machine settings, adjusted entries,
new logs) on both phone and watch. deletedLogIDs is new: additive on the
wire and cache, phone-authored (deleteLog), pruned after a 30-day grace.
Because an older build rewriting a file would strip the tombstones and
resurrect a deleted exercise, WorkoutDocument schema bumps 4->5 (forward
gate quarantines old builds) and the cache bumps 5->6.
recomputeStatusFromLogs takes an injectable now: so the merge recomputes
status/end deterministically. WorkoutMergePlannerTests pins the decision
table (commute, per-log newer-wins, legacy-nil, watch-add, tombstone
honored/resurrect/union/prune, status recompute, no-op re-push);
WorkoutDocumentMapperTests gains the deletedLogIDs round-trip.
The HKWorkoutSession that keeps the watch app foregrounded was ended only
by ActiveWorkoutGateView's onChange(of: activeWorkouts) — a view-level side
effect. When a run ended from the phone while the watch app was
backgrounded (kept alive only by the session) or torn down and rebuilt with
an already-empty list, that onChange never fired: the session leaked (the
app kept re-foregrounding on every wrist raise) and finishAndSave() never
ran, so the HR/energy summary was neither saved to Health nor forwarded.
Move session-end off the view into a long-lived WorkoutSessionCoordinator
owned by WatchAppServices, driven by a new bridge.onWorkoutsChanged
callback fired after every authoritative cache mutation (phone push or the
watch's own optimistic edit). The decision is a pure SessionEndPlanner
seam (mirrors WatchCacheApplier): a running session ends only on a genuine
non-empty -> empty transition of the active set, so the launch race (session
running before the run doc syncs) resolves to .none and never discards a
run we haven't heard about yet. Same move as the live-mirror's
repairFromDurable, one layer down.
Watch-only; no schema or wire change. SessionEndPlannerTests pins the
decision table; the OS-initiated-end path (system ends the session itself)
stays a documented residual in PLAN-watch-session-end.md.
Sequence and state diagrams for every phone↔watch interaction (splits,
exercise drive, run end, edit locks, cold launch, session lifecycle),
companion to WATCH-SYNC.md's channel reference.
Claude-Session: https://claude.ai/code/session_01PVNBVKp5bcq52X722uMjwT
The rest/timed-work countdown deadline is shared by both devices, but the
page flip crossing it is a local ticker event — and stamping the *next*
phase's anchor at Date() when that event finally ran baked a sleeping
watch's lateness (throttled wrist-down ticker) into its next count-up,
leaving the two devices permanently offset with nothing on the wire to
correct.
Auto-advances now chain the anchor instead: the finished phase's computed
end (passed out of CountdownPhaseView) becomes the next page's PageAnchor,
with the next window derived from it via liveSnapshot(for:at:). A device
arbitrarily late to a boundary shows exactly what the on-time device shows,
and a stack of missed boundaries fast-forwards itself — each chained page
lands already-elapsed and advances on its own next tick, skipping the
start/stop haptics for boundaries that passed while asleep (only a
just-crossed boundary buzzes).
The remoteAnchor* fields are generalized into one PageAnchor (remote frames
and chained auto-advances are the same concept: a page whose timer counts
from a known instant); the phone's Live Activity emit honors it unchanged.
Live frames ride sendMessage, which is reachable-only — and phone→watch
reachability drops exactly when the user is swiping on the phone (wrist
down). A frame that failed to send was staged but never retried until a
reachability edge, and a frame lost outright desynced the run until the
next human transition — sometimes forever, since a reconnect could even
re-send the stale staged frame and yank the peer backwards.
Four fixes, symmetric on both bridges and both run screens:
- A send that fails while nominally reachable now retries with a short
backoff (a few times per staged message) instead of being swallowed.
- Receiving a frame that outranks the staged outbound one drops the
staged frame, so a reconnect re-send can't move the run backwards;
a delivery the staged frame outranks is ignored as stale.
- Every staleness comparison now tie-breaks the shared version sequence
on the frame's wall-clock anchor (LiveProgress.isNewer) — after a
lost frame both devices can mint the same version, and the later
human action must win.
- Durable repair: when the absorbed workout doc shows sets completed
beyond anything the open run screen recorded or followed, it jumps
forward to the first unfinished set's work page — a lost frame now
degrades to a briefly-stale page instead of a stuck one.
The half-and-half split (paged timer over the looping form-guide figure)
is now iPhone-only. On the watch the ExerciseProgressView TabView fills the
whole screen — the figure slot is gone from both the active flow and the
Completed state, and the ExerciseFigure sources and ExerciseMotions
resources are dropped from the watch target since nothing there uses them.
Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
The orbit was always a real camera orbit — figure and props share one
rigid rotation — but a bar's screen-space angle authored the wrong 3D
rod: the default horizontal encoded a rod along the body axis, so
barbells hovered fixed on screen and vanished at the head-on view
where they should span widest. Line props now take "axis": "z" (both
renderers in lockstep, fixture-pinned): the world left-right direction
projects through the camera pitch like the floor quad — end-on plates
in profile, full span face-on, swinging with the hands in between.
Applied to the ten cross-body bars; vertical handles were already
orbit-invariant.
Goblet Squat's hand pins sat so close to the shoulders that the
two-bone IK was degenerate, flipping between a chicken-wing and an
elbow-behind solve; re-pinned level with the shoulders so the elbows
tuck straight down through the whole rep.
Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
The library grows to 64 exercises: arm circles, torso twist, leg
swings, hip and neck mobility, marching, calf raises, and a full
stretching set (forward fold, quad, calf, chest, triceps, hip flexor,
butterfly, cobra, child's pose), each with an authored motion rig and
reference page. Eight new starter splits join the catalog — Upper and
Lower Body Warm-Up, Morning Wake-Up, Morning Mobility, Full Body
Stretch, Evening Stretch, Free Weight Basics, and Full Body Machines —
regenerated deterministically at split schema v3 with fixed ULIDs.
Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
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
iCloud Drive writes now flow through a persistent WriteBacklog sidecar
(drained with backoff, flushed on backgrounding, wiped with the cache on
account change), so a save can never be lost to a transient coordinator
error. A status banner on the workout list surfaces stuck syncing.
Also: the split picker gains a Recent section with day labels, split
rows fold SplitItem into SplitListView, and list rows dim the multiply
sign in sets-by-reps.
Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
Changelog entries for the Live Activity, mid-workout library adds,
backups, diagnostics, watch-only Health recording, navigation and
accessibility improvements, and the bug fixes; README key features
updated to match.
Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
A new Settings > Diagnostics screen reports the ubiquity container and
account status, per-document download and eviction state, network
readiness, and a count of documents skipped by the schema-version
forward gate — surfaced to help debug why a file isn't syncing.
Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
Adds a local ZIP backup/restore of the iCloud document tree via the
IndieBackup package, surfaced in Settings with retention controls. A
restore suspends the sync observer, mirrors the files, then rebuilds the
SwiftData cache; opening a shared .workoutsbackup file restores it. The
engine exposes the container Documents root and a restore lifecycle
(isRestoring guards a concurrent connect), and the backup file type is
registered for open-in-place.
Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
A new iOS widget extension shows the active exercise, its phase, and the
work/rest countdown on the lock screen and in the Dynamic Island, driven
by the run flow's live frames so locking the phone mid-set keeps the
timer. The activity is seeded on open, refreshed on every page settle,
dismissed when the flow is left, and cleared on next launch if stranded.
Unifies the build-info stamping across all targets via a YAML anchor.
Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
Starting a workout — from the split picker or a split's exercise list —
now drops straight into its log screen once the cache catches up, via a
shared StartedWorkoutNavigator. Adds VoiceOver labels/values to the log
checkboxes and the settings button, a color-independent numbered legend
and spoken summary to the heart-rate-zone bar, and Dynamic Type scaling
to the run-flow badges and timer.
Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
New Workouts Watch AppTests bundle wired into the watch scheme. Extracts
the phone-to-watch cache apply/prune into a pure, session-free
WatchCacheApplier seam and makes the HR-zone bucketing a nonisolated
static, so both can be unit-tested off the main actor without a live
WatchConnectivity session.
Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
The phone no longer writes estimated Health workouts: the watch, which
runs the live session, is the sole recorder. Replaces WorkoutHealthWriter
with a WorkoutHealthDeleter that only removes a legacy phone-estimate
workout when its record is deleted here, drops the MET calorie table and
the phone's write/read Health scopes, and keeps phoneEstimate decodable
for existing documents.
Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
Editing a workout's start date now removes the file at its old month
bucket so the record no longer duplicates on the next reconcile. Seed
reconcile re-checks the tombstone veto before overwriting an upgraded
seed. The watch applies authoritative-empty pushes so remote deletes
prune, and a re-saved finished workout keeps its original end time.
Adds unit tests for the mappers, path bucketing, and status machine.
Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
Pick any exercise from the full library while a workout is running, not
just the ones in its split. The new exercise's plan is seeded from the
most recent log of that exercise, else the library's authored Defaults
line, else a plain 3x10. Adds a searchable two-section picker sheet and
a **Defaults:** bullet to all 47 library reference pages.
Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
Weight moves up beside the exercise name (matching font), sets × reps
sits below it with a dimmed ×, and recorded machine settings show as a
secondary line under the name — no placeholder when unconfigured.
Single-line bodyweight rows center against the checkbox.
Claude-Session: https://claude.ai/code/session_01P152LxjZ4vePHsSorQa5Jf
Abdominal's pinned hands used elbow -40 as the IK plane hint, drawing
the arms hyperextended (user-reported). Flipping the hint bends the
elbows the natural way while the hands stay on the handles. Same class
of fix for the milder cases: Arm Curl and Shoulder Press elbows and
Calfs knees clamp to -8, Side Plank's raised arm to the -70 ROM cap.
The whole library now passes render.py --strict with zero warnings,
making it a valid verification gate. Fixtures regenerated; 48 tests
green.
Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
render.py --export bundles the 25 new motion rigs and info pages;
figure-fixtures.json regenerated for all 47 (the Swift solver reproduces
every new motion, including negative-y pins and the new prop uses). The
fixture count assertion moves to 47, and the no-bundled-motion test now
uses Treadmill - a permanent COVERAGE.md exclusion - since Bench Press
exists. 48 tests green; watch target builds. Coverage handoff complete -
TODO-coverage.md retired.
Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
Pec Deck and Rear Delt Fly author the face-on and from-behind cameras;
Triceps Pushdown and Face Pull are the cable station's two entries per
the coverage model (cable curl/crossover are considered exclusions).
Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
The biggest hole in the coverage matrix - the hinge had zero entries in
any modality. Deadlift and Romanian Deadlift are the library's first
standing (camera.zoom) and first barbell motions.
Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
The rig is ~211 canvas units tall standing but the canvas has 152 above
the ground line - every motion to date is seated or on the floor. A
per-motion "camera": {"zoom": ...} now scales the drawn output (figure,
props, mat, stroke widths) about the ground-center anchor in both the
reference renderer and the in-app view. Pure view transform: pins, prop
coordinates, and the Swift-solver fixtures stay in full-size authored
units; zoom 1 is byte-identical to before.
Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
With the equipment layer carrying world-space 3D form, the prop-free
gate comes off: machines now get the same slow orbit as the bodyweight
moves, their seats, cables, bars, and rollers turning with the figure.
Closes out the orbit-for-all-exercises plan.
Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
Hollow Body Hold, Leg Raises, and Reverse Crunch hid the far arm
because it was never authored - a leftover from the planar rig. Both
arms are now posed, so the far arm reads as the standard light member
behind the near one, the same visual language every other exercise
uses, and the figures stay truthful from any viewpoint. With no
remaining users, the hide mechanism is deleted from both renderers,
the motion schema, and the docs.
Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
Scene shapes, cable anchors, bar angles, pad perpendiculars, and roller
offsets all resolve in the authored view exactly as before, then rotate
about the world-vertical axis through the root anchor - the same
resolve-then-rotate pattern as the figure's pins and the mat - so at the
authored yaw every exercise renders bit-identically to today, and under
an orbiting camera the equipment turns with the figure while staying
welded to its hands and feet. Scene lines gain an optional depth plane
(z) and slab extrusion (depth) so seats, backrests, and platforms keep
form edge-on; the rect shape is retired (re-authored as slab lines).
All 14 machines' props re-authored with depths and verified at eight
orbit angles. The fixture snapshots move into the pipeline as
render.py --fixtures and now cover orbit-presentation samples with
resolved prop primitives for a spread of prop flavors; the in-app
renderer resolves props in MotionSolver (lockstep with resolve_props)
and the view just draws primitives.
Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
The floor rectangle was screen-locked, which broke the illusion the
moment the camera orbited. It is now a world-space quad on the ground
plane, sized to each motion's projected footprint across its key frames
and rotated through the same camera as the figure - a long rectangle in
profile, a parallelogram mid-orbit, end-on when face-on. Both renderers
in lockstep; fixtures unaffected (the mat is a pure addition).
Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
The default camera pitches down 10 degrees, so the floor reads as a
plane (drawn as a rectangle) and near/far contacts straddle it.
Elevation is pure presentation - IK pins solve in the flat authored
view and the posed body tilts, the same pattern as the orbit, so
authored canvas targets never go out of reach. The leg-extension
roller moves up onto the shin above the ankle and the leg-curl roller
tucks under the heel. Fixtures and reference test values regenerated
for the pitched camera.
Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
Shoulder and pelvis widths grow to human-like proportions per profile
(shoulders wider than hips for neutral/male, reversed for female) and
are now drawn — bars across the attach points that read near-full-width
face-on and as a shoulder/hip nub in profile, so limbs visibly hang
from a torso instead of a point. Orbiting no longer re-solves IK pins
in the rotated view (pins are canvas targets in the authored camera):
the pose resolves first and the posed body rotates, which fixes hands
sticking to stale screen points mid-orbit (Cat-Cow, Bird Dog, Plank).
Leg Extension and Leg Curl swap their ankle bars for a machine roller
disc — a new `roller` prop riding the shin's press side. Fixtures
regenerated; both renderers updated in lockstep.
Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
Bodyweight motions (no equipment layer, no hide list) now turn the
camera a full revolution every 24 seconds while the motion loops, so
the exercise reads from every side; machine exercises keep their
authored view, since scene equipment is a view-locked billboard and a
hide list describes a single viewpoint.
Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
The library's planar world-angle rig becomes a genuine 3D anatomical
model: skeleton.json holds bone-length profiles (real shoulder/pelvis
widths, feet, neutral/female/male) and per-joint ROM; motions pose
joints with anatomical angles (flexion/abduction/rotation from neutral
standing) under a per-exercise orthographic camera, resolved by
kinematics.py (3D FK, analytic two-bone IK with anatomical write-back)
and validated against physiological ranges. All 20 sagittal motions
were migrated by planar decomposition with 0.00 px golden parity against
the old renderer — relabeled to true anatomy, since shading is now
near-dark/far-light by camera depth rather than by limb suffix — and
the face-on machines are re-authored honestly: Abductor/Adductor with
real hip abduction (the foreshortened "frontal" profile is retired) and
Rotary with genuine spine axial rotation. Figures gain articulated
feet; profiles swap without touching a single motion script; --orbit
sweeps the camera 360° while a motion loops.
The in-app SwiftUI renderer (iOS + watch) is ported to the same model
and consumes the exported motions verbatim; figure-fixtures.json pins
its geometry to the Python pipeline within 0.5 px across every
exercise, key frame, tween, and orbit sample. Also makes the watch
bridge logger nonisolated for the newer SDK's stricter isolation
checking.
Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
The watch now re-applies the phone's application context once activation
completes (real hardware activates asynchronously, so the eager launch
read sees an empty context), and a state push that fails to decode —
a phone/watch build running different document schemas — is logged and
skipped instead of pruning the cache against a bogus empty set. Workout
log rows offer the machine-settings editor for any machine-based library
exercise, not just logs that already carry settings.
Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
Library detail screens now lead with the user's recorded machine settings
(per-split when they disagree, empty-state card for machine-based entries)
and append the weight progression chart. Starter seeds mark machine
exercises with an empty machineSettings list so the settings UI lights up
before first use. The figure rig gains a frontal body profile for face-on
machines, props that can ride mid joints (knees/elbows), and an
alternating four-frame Bird Dog loop.
Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
Seeding now covers existing installs, not just empty containers: after the
same settle delay as auto-seed, connect() branches to reconcileSeeds(),
driven by a pure tested planner — upgrade an older seed revision in place
(safe: clone-on-edit guarantees no user content at seed ULIDs), skip
up-to-date/quarantined files, respect delete-veto stubs, and write missing
seeds unless a same-name legacy split exists. Settings gains Restore
Starter Splits (the one deliberate veto lift, writing current bundle
bytes) and a dev duplicate-cleanup tool backed by a fail-closed scanner.
The HealthKit estimate now follows the clone redirect when resolving a
workout's split.
Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
Machine-based exercises now carry ordered name/value comfort settings
(seat height, back-rest position, ...) on both ExerciseDocument and, as a
plan-time snapshot, WorkoutLogDocument — the optional array doubles as the
machine flag. Editable from the exercise editor's new Machine section and
from the workout row's settings sheet, whose mid-workout edits write back
to the originating split (workout saved first, so seed clone-on-edit
repointing can't clobber the log edit).
Schema tightening rides the same rev: splits bump to v2 (weight-reminder
fields and the unused exercise category removed), workouts to v3 (the
derived `completed` flag removed; status is the single source). Starter
seeds regenerated at v2 with unchanged ULIDs; SwiftData cache schema
bumped to rebuild. SCHEMA.md documents the shapes.
Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
Fourteen Planet Fitness machine exercises join the rig library, each with
authored motion, info page, and schematic equipment via the new props layer
(scene shapes, cables, bars, pads) rendered in lockstep by render.py and the
in-app figure renderer. Abductor and Adductor are authored face-on with the
real seated machine motion — knees-bent legs swinging apart/together against
knee pads — replacing the earlier middle-split depiction. The watch target
now bundles the figure renderer and motion rigs too.
Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
The exercise picker now lists the bundled exercise library — the
motion-rig exercises exported from Exercise Library/ — instead of the
two *.exercises.yaml starter catalogs. ExerciseListLoader and the Yams
dependency are removed; ExerciseMotionLibrary gains exerciseNames, the
bundle enumeration the picker reads.
Starter splits ship as byte-canonical SplitDocument JSON with fixed
ULIDs (Workouts/Resources/StarterSplits, regenerated by
Scripts/generate_starter_splits.swift) and auto-seed after connect into
a verifiably empty container, re-checked after a settle delay — wrong
guesses are harmless because identical bytes make same-path conflicts
empty and tombstones reap resurrected seeds. Seeds are immutable:
SyncEngine.save(split:) forks an edited seed to a fresh ULID and
soft-deletes the original, whose stub is exempt from pruning
(IndieSync 0.3.0 prune(exempting:)) and vetoes resurrection forever;
split views resolve by id through a redirect map to follow the swap.
Add Starter Splits in Settings restores deleted seeds by lifting the
veto stub and rewriting the bundle bytes.
Also fixes ingestFromWatch bypassing the tombstone veto (a phone-deleted
workout resurrected when a stale watch resent it) and reaps a live file
immediately when its tombstone arrives.
SplitDetailView also picks up the category-grouped exercise sections
from the exercise-category work.
Exercise Library/ holds per-exercise reference docs (setup, cues,
mistakes, progressions) with SVG visuals and a Python-rendered motion
pipeline; Workouts/ExerciseFigure renders the bundled *.motion.json
rigs as animated stick figures on the exercise screen. Exercises gain
a warm-up/main-circuit category, timed exercises display hold time via
planSummary, and a completed exercise reopens to a check screen instead
of its timers.
No unit test target existed (testTargets: [] in scheme). Adds a
bundle.unit-test target wired into the Workouts scheme, with a smoke test
that round-trips SplitDocument through IndieSync's DocumentCoder — the
same codec DocumentFileStore uses for every iCloud document.
Remove the per-app asc-*.swift copies (now maintained only in
indie-skills/skills/appstore-publish/scripts/) and add a follow-up hint to
release.sh pointing at the canonical, in-place invocation with Workouts'
bundle ID and marketing version.
Replaces the repo-local Scripts/update_build_number.sh (which git-tagged
on every build) with the shared update_build_info.sh referenced in place
from indie-skills: same commit-count CFBundleVersion and BuildDate, adds
BuildHash, and makes release tagging opt-in via RELEASE_TAGGING=1.
Verified BuildDate/BuildHash/CFBundleVersion land in the built app.
Replaces the app-local copies of the extracted storage core with the
IndieSync 0.1.0 package (pinned from the new tag): ICloudFileManager ->
DocumentFileStore + TombstoneStore, ICloudFileMonitor ->
MetadataObserver (batched events with the content-date churn gate),
and the shared ULID / DocumentCoder / Tombstone / VersionedDocument
pieces. SyncEngine stays app-specific and is rewired onto the package
types; documents rename currentSchema -> currentSchemaVersion to adopt
the package protocol. ULID.make() remains as a shim minting the string
form the app keys on.
Behavior-preserving: identical JSON bytes (same coder config), same
stub wire format (kind encodes as the same strings), same soft-delete
ordering, same 30-day prune. WorkoutDocument keeps its local-calendar
month bucketing rather than adopting TimeBucketedLayout's UTC paths --
changing derivation would strand existing files. The watch target
links the package too (ULID + DocumentCoder via Shared); iOS, watchOS,
and widget targets all build.
Closes the last open loop of the HealthKit feature (decision C from
HealthKit-Delete-Loop.md, now retired): the history-list delete dialog
gains a "Delete + Remove from Apple Health" option, shown only when the
workout has a Health record. Removal is best-effort via the workout's
stored HKWorkout UUID; watch-recorded workouts are authored by the
watch app's separate HealthKit source and may be refused, so the dialog
notes they may need deleting in the Health app instead. Discarding an
in-progress workout is unaffected (no Health record exists yet).
Watch sessions now run an HKLiveWorkoutBuilder: heart rate and active
energy are collected live (shown in an in-workout HUD), saved to Health
as a real HKWorkout on completion, and carried back to the phone as
WorkoutMetrics on the workout document. Phone-only workouts get an
estimated Health workout (MET x bodyweight x duration) after a 10s
debounce that a late-arriving watch session cancels; a launch sweep
backfills workouts completed within the last day. Dedupe is keyed on
metrics.healthKitWorkoutUUID plus the metrics source.
Splits gain an activity type (strength, functional, HIIT, core, cardio,
cycling) that categorizes the Health workout and picks the MET value.
A post-workout summary sheet (duration, calories, avg/max HR, HR zones,
total volume) fills in live and is also shown on completed workouts.
Weights can now display in lb or kg (display-only relabel), synced to
the watch over the existing application context.
WorkoutDocument schema 1->2 (metrics are irreplaceable, so older apps
must quarantine rather than strip them); cache schema 2 rebuilds the
SwiftData store with the new metric columns. Deleting a workout in the
app intentionally leaves its Health record in place.
Ports QuickRabbit's resolveConflictsIfAny (R4.3) into the eviction-safe read
path: before every coordinated read, pick the NSFileVersion with the latest
modification date, promote it to the canonical file inside a coordinated
write, mark all conflicts resolved, and prune the losers. Without this,
conflict siblings from two-device edits (workout logs edited mid-session are
the risky case) accumulate silently and a read returns whichever version the
filesystem hands back.
Claude-Session: https://claude.ai/code/session_01SY5jsAUf4qoPxSvv8xAqXS
Wires the previously-dead ensureDownloaded into every read (it now polls,
bounded 30s, instead of fire-and-forget) and fixes the deeper hole: the
directory listing skipped hidden files, so evicted records — whose only local
trace is a hidden .<name>.json.icloud placeholder — never appeared in
reconcile at all and their cache entities were pruned as deleted.
Also: reconcile no longer prunes entities whose file failed to read this pass
(an eviction-download timeout is not a deletion); tombstone IDs come from stub
filenames so an evicted stub still vetoes resurrection; the stale-file stub
check is placeholder-aware; read failures log loudly instead of try?-skipping.
Verified with a harness compiling the real ICloudFileManager against a local
temp directory (placeholder mapping, dedup, tombstone derivation, soft-delete
round trip). Real eviction/download behavior needs a device pass.
Claude-Session: https://claude.ai/code/session_01SY5jsAUf4qoPxSvv8xAqXS
The toolbar overflow affordance didn't feel native to iOS 26. Restore a direct + button (primaryAction placement) that opens the exercise picker, and move End Workout to a dedicated action row at the bottom of the exercise list. Removes the intermediate actions sheet and its menu/relay plumbing.
Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
Convert the ⋯ toolbar Menu in WorkoutLogListView to a Button that presents a small detented sheet, so the Add Exercise / End Workout actions appear at the bottom within thumb reach instead of anchoring to the top under iOS 26. The tapped action is stashed and run from the sheet's onDismiss, since SwiftUI presents only one sheet at a time.
Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
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
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
Stop giving up on a slow iCloud container: a freshly enabled iCloud Drive
that reports nil while still provisioning is now polled patiently for up
to ~10 minutes instead of failing fast to the "unavailable" screen. A nil
ubiquity token (not signed into iCloud at all) still fails immediately,
and the connecting screen reveals an escape hatch at 28s for users who'd
rather jump to setup than keep waiting.
The connecting and iCloud-required screens are now branded — a purple
gradient with teal accents — and the spinner becomes a custom comet-arc
ConnectingIndicator around an iCloud glyph. Connecting copy escalates
with the wait so a slow first connect reads as steady progress.
Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
XcodeGen wrote no scheme files by default, so Xcode fell back to stale
xcuserdata that omitted the iOS app — leaving the Workouts scheme absent
from the run destination dropdown. Declare scheme blocks on both app
targets so real shared schemes are generated.
Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
In the Always-On / wrist-down state, watchOS throttles the system timer
text and collapses sub-minute values to a relative "< 1 min", which
overflows the 50pt counter font and truncates with an ellipsis.
Gate PhaseTimerLayout on isLuminanceReduced: keep the live system timer
while active, but render our own held "~m:ss" snapshot from the wall-clock
anchors in Always-On — so the count stays numeric and on-screen. Timing is
unaffected (haptics/auto-advance still run off the anchor, not the text).
Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
Stage the latest live-run frame (and the terminal .ended marker) in a
depth-1 latest-wins slot on both bridges, and flush it on reachability/
activation restore. A brief connectivity drop no longer desyncs the
iPhone/Watch run mirror; frames carry a wall-clock anchor so a late
re-send self-corrects rather than reading as stale.
Claude-Session: https://claude.ai/code/session_01A9CfUa4E9Zd5swfoNsYPs7
Rotate the dumbbell glyph -35° (counter-clockwise) in the circular,
corner, and rectangular accessory families so it runs lower-left to
upper-right like the app icon. Inline stays upright since that template
renders the symbol on the time line.
Two-way driving only worked watch->phone: the watch's navigated driver
broadcast and the phone auto-presented a follower cover. The reverse
failed on both ends — the phone's in-list ExerciseProgressView never
broadcast (only its cover did), and the watch had no surface to present
an incoming run.
- Wire the live channel into the phone's in-list driver (broadcast +
follow) via a progressView(logID:) helper in WorkoutLogListView.
- Add a watch follower cover (LiveRunCoverView, mirroring the phone's),
presented from ContentView when the phone drives a run the watch isn't
already in; the watch bridge gains presentable / muteLive.
- Add a navigatedRunID guard on both sides so a device already in the run
follows it inline rather than stacking a cover over itself.
Now starting or driving on either device surfaces the run on the other —
as a follower cover when idle, or inline when already in that run — and
either side can take over.
Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
The two-way live run unified the propped-phone mirror onto the real
ExerciseProgressView driver, whose phase views anchor their timers to a
local Date() captured when the page becomes active. For a remote-driven
transition that meant anchoring at phaseStart + delivery-latency +
render-time, so the receiver's countdown lagged the driver — visibly so
iPhone->watch, where the watch is the slower receiver (apply -> SwiftUI
re-render -> TabView animation -> phase view activates).
The frame already carries phaseStart/phaseEnd wall-clock anchors (the way
the old read-only mirror counted off them). Honor them: when a phase is
reached by applying a remote frame, the active phase view anchors its
timer to the frame's phaseStart/phaseEnd instead of local now. Scoped to
the remote-driven page (remoteAnchorPage) so a local swipe or auto-advance
still self-anchors; any local transition clears it. Applied symmetrically
to both the iPhone and watch drivers.
Delivery latency no longer shifts the displayed timer in either direction
(limited only by sub-second inter-device clock skew), and the auto-advance
at zero fires together since both share the same phaseEnd.
Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
A static WidgetKit accessory widget (launcher only — no data sharing,
App Group, or entitlements). Tapping any accessory widget opens its
containing app, so this is enough to place a Workouts button on the
watch face. Supports the circular, corner, inline, and rectangular
accessory families.
New 'Workouts Watch Widget' app-extension target embedded in the watch
app via project.yml.
The propped-up iPhone now runs the real ExerciseProgressView for a live
watch workout instead of a read-only mirror, and the live-run channel is
symmetric — either device can drive the flow and the other follows.
Each page transition is classified human / auto / remote: only human
transitions (swipe, Start, One More, swipe-back reset) are broadcast and
recorded by the actor; auto-advances (rest / timed-work countdown) record
locally but aren't sent, since both devices reach them independently off
the shared wall-clock anchors; an applied remote frame jumps the page
without re-recording or re-broadcasting. That rule is also what stops an
echo loop.
- PhoneConnectivityBridge gains sendLiveProgress/sendLiveEnded (the
missing phone->watch direction); WatchConnectivityBridge receives
frames into an observable liveIncoming via a new didReceiveMessage
route. Both share one increasing per-run version sequence so the
stale-frame guard works across the two devices' counters.
- Both ExerciseProgressViews gain an incomingFrame input + applyIncoming
(syncing setCount for a remote One More); the iPhone one gains the
liveSnapshot/broadcast machinery the watch already had.
- New LiveRunCoverView wraps the real driver for the cover (resolves the
workout, persists via SyncEngine, wires the live channel + close);
ContentView presents it; LiveProgressMirrorView is removed.
Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
Move the idle-timer disable out of ExerciseView/ExerciseProgressView and
up to the app scene, re-asserting it whenever the scene becomes active
(iOS clears the flag on background). A propped-up phone now stays lit for
the entire workout, not just while an exercise is open.
Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
Add an ephemeral live-run presence channel (separate from the durable
iCloud progress sync) so a propped-up iPhone can mirror the Watch's
Ready → work/rest → Finish flow in real time as the user swipes.
Watch drives, phone mirrors (read-only), so there's no echo loop:
- Watch's ExerciseProgressView broadcasts a LiveProgress frame on every
phase transition (and an ended signal on leave) via sendMessage,
reachable-only — throwaway presence, never written to iCloud.
- Timers ride as wall-clock anchors (Date kept native in the WC dict to
preserve sub-second precision), so both devices count independently
off shared start times and stay in lockstep without streaming ticks.
- Phone holds a transient LiveRunState; ContentView auto-presents a
read-only LiveProgressMirrorView full-screen cover while a run is live.
Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
Publish an exclusive-edit lock (editingWorkoutID / editingSplitID) in the
phone→watch application context. While the phone has a workout's exercise
(ExerciseView) or a split (SplitDetailView) open in an editor, the watch pops
out of that run, blocks re-entry, and shows it as "Editing on iPhone" — so the
two devices never drive the same run at once and the watch can't clobber the
phone's edit with a stale optimistic write. The lock clears when the editor
closes; absent keys in the latest-wins context mean "not editing".
Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe
Tapping an in-progress exercise on the watch froze the app in an infinite
SwiftUI re-render loop. WorkoutLogListView.body observed SwiftData two ways
the iPhone list deliberately avoids: a @Query-bound Split, and a traversal of
its `exercises` relationship during render (availableExercises). Reading an
observed model's relationship inside body keeps the view perpetually
subscribed and re-invalidating. Fix: fetch the split imperatively (not via
@Query), gate the Add-Exercise affordances on the value-type doc.splitID, and
evaluate availableExercises only from the picker sheet's closure. The list
body now depends solely on the value-type working doc.
Also remove the temporary on-screen diagnostics/PVDiag plumbing and restore
PhaseTimerLayout and the dot-row animation that were dropped while debugging.
Make the Ready? page always lead the exercise flow on both watch and iPhone
(previously only for not-started exercises), so a resumed run can swipe back
to it. A deliberate swipe back to Ready? resets the run; the transient paged
TabView snap-to-0 on open is guarded by a settle gate plus an adjacent-swipe
check, so an in-progress exercise lands on its set and is never reset on open.
Claude-Session: https://claude.ai/code/session_01SCv7zvGFcKy47KSTnTLxRe