Commit Graph
156 Commits
Author SHA1 Message Date
rzen b6046bd0a9 Add a starter routine gallery with per-seed restore
The Library tab's routines pane gains a "Browse Starter Routines" entry
opening a gallery of every bundled seed — kept or deleted — each showing
Added / name-taken / restorable state, a read-only plan preview, and a
one-tap Add that lifts the seed's delete veto. A "Re-add All Missing
Starters" fallback replaces the all-or-nothing Settings button, which is
removed. The delete confirmation now also warns how many scheduled days
would be left behind on the Today board.

Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
2026-07-15 11:14:14 -04:00
rzen 586804f771 Add the Library tab: reorderable routines pane and searchable grouped exercises pane
A fourth tab promotes routine and exercise management out of Settings.
LibraryView switches between two segments on one navigation stack. The
routines pane (RoutineListView reborn as RoutinesLibraryView) sorts by
the user's order, gains drag-to-reorder writing only changed orders,
swipe actions for duplicate/edit/delete, a Starter badge on bundled
seeds, and a last-trained caption. The exercises pane groups the library
into curated category sections searchable by name, category, or muscle.

Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
2026-07-15 10:58:57 -04:00
rzen 16b61946d5 Treat order as presentation in seed logic; add per-seed restore and routine duplication
Drag-to-reorder writes Routine.order through the document, which used to
read as a content edit: isPristine would fork a starter for a mere reorder,
and reconcile's semantic compare would clobber a reordered seed file back
to bundle order. Both now normalize order (and updatedAt) away — a fixed-
ULID file still never holds user content; ordering is bookkeeping.

SyncEngine gains restoreSeed(id:) — the per-seed analog of the bulk
restore, sharing one restoreSeedIfEligible core — and duplicate(routine:),
which copies any routine (starter or not) to a fresh ULID with fresh
exercise ids, a unique "… Copy" name, and last position in the list.

Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
2026-07-15 10:45:02 -04:00
rzen 36dad21233 Add pure helpers for routine ordering, copy naming, and a grouped exercise catalog
Groundwork for the Library tab: RoutineOrdering.changedOrders turns an
.onMove gesture into the minimal set of order writes (normalizing legacy
colliding orders on first drag), RoutineNaming.uniqueName picks the next
free "X Copy" name for duplication, and ExerciseCatalog groups the bundled
exercise library into curated category sections with name/category/target
search. Plus ExerciseDocument.planSummary for read-only document rendering.
All covered by unit tests, including a motion/info name-parity assertion.

Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
2026-07-15 10:38:43 -04:00
rzen ce4c581bd0 Remove the duplicate exercise list screen and dead reorder stubs
ExerciseListView was a near-duplicate of RoutineDetailView's exercise
management, reachable only from RoutineAddEditView's "Exercises" row —
drop both, leaving RoutineDetailView as the single exercise manager.
OrderableItem and SortableForEach were self-declared dead stubs.

Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
2026-07-15 10:38:35 -04:00
rzen c39eeda9df Port the exercise run screen's true resume to the watch
Re-entering an in-progress exercise on the watch now computes its landing
(page + wall-clock anchor) from the log's durable timestamps in init —
mid-set resumes the stopwatch from startedAt, mid-rest lands on the rest
page with the countdown continuing and auto-advancing at the true
boundary, and a spent rest anchors the next set at the rest's computed
end. Timestamps are clamped to now against peer clock skew; legacy logs
without them keep the old first-unfinished-set behavior. broadcastLive
forwards the resume anchor so a mirroring phone's timer lines up.

Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
2026-07-14 22:01:49 -04:00
rzen 4cd6e4181c Show both identities in a renamed exercise's headline and repair legacy figures
The run-screen headline now reads "Library · Name" for a renamed exercise,
keeping its origin visible next to the custom name (the separate subtitle
line is gone). Logs minted before the per-log libraryName snapshot existed
carry none, which cost a renamed exercise its figure, guide, and spoken
cues — the screen now resolves the library identity through the workout's
routine (same-named exercise, display-only) as a fallback, so those older
workouts get their animated figure back, including on the Completed page.

Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
2026-07-14 21:30:00 -04:00
rzen e8b1cb800d Give the exercise run screen its own chrome, rolling digits, and a true resume
The run screen now hides the navigation bar in both orientations and draws
its own header — back chevron top-left, exercise name (plus library name
when renamed) in large type where the toolbar was, previewing the next
exercise during a between-exercise rest. Work and rest counters are pinned
to the exact vertical center of the timer half via equal flexible bands,
with the adjust pill moved to an overlay so complications can't shift them.
Counter digits are now state-driven and roll through numericText
transitions (work count-up, rest countdown, and the Done button count).

Reopening an in-progress exercise now resumes its timer from the durable
timestamps instead of restarting at zero: no sets done anchors the
stopwatch to startedAt; mid-rest lands back on the rest page with the
countdown still running off its true window; past the window the next
set's stopwatch counts from the rest's computed end. The resume page is
computed at init because the paged TabView must initialize on it — a
post-layout backward jump wedges the pager, which then ignores the next
animated programmatic advance.

Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
2026-07-14 20:56:18 -04:00
rzen df5c77eee1 Let routines rename exercises and repeat them for interval segments
An exercise's name is now a per-routine display name: a new optional
libraryName on ExerciseDocument (snapshotted onto WorkoutLogDocument at
plan time) keeps the link to the bundled library exercise, and every
figure/info/cue lookup resolves libraryName ?? name. Deliberately not
schema-bumped — an older app dropping the key only strands the
figure link, same rationale as activityType. Cache schema bumped to 9
for the new columns.

The picker no longer filters out exercises already in the routine
(an "×N" badge marks them instead), exercise rows gain a leading
Duplicate swipe that clones an entry in place, and the edit sheet gets
a Name field with the library exercise shown read-only above it.

Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
2026-07-12 18:38:14 -04:00
rzen d04526826c Add a Running exercise with an animated stride figure
Stance and flight keyframes with single-frame foot pins for the strikes,
forward lean, opposite-arm drive, and daylight under both feet in flight —
distinct from Cardio's high-knee march, which stays as is.

Claude-Session: https://claude.ai/code/session_01PKptrgbx74peTwHGRxBojv
2026-07-12 00:37:23 -04:00
rzen 79e75a9127 Fix figure IK snapping and gate the library on a fail-hard motion checker
Three solver defects made limbs teleport, twist, or windmill: write-back
angles wrapped at ±180 and lerped the long way around; branch flips landed
on configurations the anatomical write-back cannot represent, silently
pulling pinned extremities off their pins; and the degenerate straight-limb
bend plane fell back to the camera axis instead of the anatomical anterior.
solve_limb now verifies each branch reproduces the solved end before
accepting it, resolve unwraps written-back angles toward the pose they
replace, and the degenerate plane comes from the parent's anterior axis.

render.py --check replays every exercise's full tween loop and fails hard
on six invariants (pin fidelity, continuity, wraps, authored-vs-resolved
drift, ground penetration, resolved ROM); --export refuses to ship a
failing exercise. All 66 motions re-authored or retouched to pass: honest
authored angles where pins used to override them silently, grounded feet
on the seated machines, a vertical bench-press bar path, straight-armed
child's pose, a butterfly stretch seated on the mat, and FK arms where
pins forced impossible reaches. MotionSolver.swift mirrors the solver
changes line for line, held by regenerated fixtures.

Claude-Session: https://claude.ai/code/session_01PKptrgbx74peTwHGRxBojv
2026-07-12 00:37:23 -04:00
rzen 400601283e Add a debug figure-review pager under Settings > Developer
Swipe through every bundled exercise rig, toggling between the looping
figure and its authored reference page, to QA the library on device.

Claude-Session: https://claude.ai/code/session_01PKptrgbx74peTwHGRxBojv
2026-07-12 00:37:05 -04:00
rzen 530021c2de Give the Progress tab a rotating daily title greeting
Claude-Session: https://claude.ai/code/session_01PKptrgbx74peTwHGRxBojv
2026-07-12 00:37:05 -04:00
rzen c24bc9cfeb File ad hoc workouts under their routine's goal on the Today board
An ad hoc workout was only launched ad hoc — any schedule referencing the
same routine (through the clone redirect) still says what the user trains
it for. Infer the goal from those schedules, tie-breaking by the user's
goal order; only truly unassigned workouts stay in Unassigned.

Claude-Session: https://claude.ai/code/session_01PKptrgbx74peTwHGRxBojv
2026-07-12 00:37:05 -04:00
rzen 7b6c39d8f0 Guard every persisted read of retained workout models with isLive
A remotely deleted @Model traps on any persisted-property read, including
the observed expression of .onChange, which SwiftUI evaluates on every
body pass. Guard those expressions, the onAppear takeover read, and the
summary sheet's document mapping.

Claude-Session: https://claude.ai/code/session_01PKptrgbx74peTwHGRxBojv
2026-07-12 00:37:05 -04:00
rzen 8854ad59d5 Add per-routine target heart rate with live in-run indicator
Endurance routines (HIIT, cardio, cycling) can carry an optional target bpm
(RoutineDocument.targetHeartRate, not schema-bumped — same preference-field
rationale as restSeconds/autoAdvance), snapshotted onto the WorkoutDocument at
plan time like the other pacing fields.

During a run, the watch streams its live HR sample to the phone over a new
best-effort liveHeartRate message — deliberately outside the LiveProgress
machinery (no version bump, no staging/retry; a gauge, not a record), throttled
to changed-bpm-or-10s in the watch bridge. LiveRunState holds the sample with a
30s staleness auto-clear so a dead stream never shows a frozen number.

Both run screens show the reading only when the run carries a target: the
phone's ExerciseProgressView as a top pill, the watch's in the top-trailing
toolbar slot, each tinted by a shared ±5 bpm HeartRateBand with an arrow cue to
push harder (low) or ease off (high) — e.g. dialing in a treadmill incline to
hold a steady effort.

Claude-Session: https://claude.ai/code/session_01Y7ZhkCYWNiTSAFhFCGnJ8n
2026-07-11 19:51:44 -04:00
rzen b06a44eb40 Hide the tab bar when the exercise run screen is in landscape
The landscape side-by-side split (paged flow | form-guide figure) is already
starved for height; keying tab-bar visibility off the same compact-vertical
size class that drives the split gives the run the full screen. Applied across
all three body branches (run flow, Completed, Skipped).

Claude-Session: https://claude.ai/code/session_01Y7ZhkCYWNiTSAFhFCGnJ8n
2026-07-11 19:49:55 -04:00
rzen b1a5d25b61 Give achievements tappable detail pages with how-to-earn and why-it-matters copy 2026-07-11 13:40:36 -04:00
rzen e509d0e7c5 Keep schedules attached to routines across seed clone-on-edit forks
Editing a starter-seed routine forks it to a fresh ULID, but only workouts
were durably repointed — schedules kept the dead seed id, and after a
relaunch (empty in-memory redirect map) the Today board and edit form saw
the routine as gone.

- cloneSeedOnEdit now repoints schedules too (routineID + routineName
  track the clone; a schedule's name is a live-pointer fallback, unlike a
  workout's frozen run-as name)
- A launch-time repair pass (pure ScheduleRepairPlanner + tests) heals
  already-broken references by re-attaching a dead-pointer schedule to
  the unique live routine matching its remembered name
2026-07-11 13:02:55 -04:00
rzen 76150070f8 Show the selected routine as a symbol-and-color preview in the form
The Routine row now renders the selection like the picker list's rows
(tinted symbol + name); with nothing selected it reads "Please select"
in secondary style.
2026-07-11 12:50:21 -04:00
rzen 1a1147b612 Resolve the seed redirect when editing a schedule's routine
A schedule made against a starter-seed routine keeps the retired seed id
after the routine is edited (clone-on-edit). The edit form matched that id
directly, so the routine read as unselected and Save stayed disabled; it
now resolves through sync.currentRoutineID like the Today board does, and
the routine picker's checkmark compares against the resolved id.
2026-07-11 12:40:18 -04:00
rzen 5ec5de9295 Add reminder notifications to schedules
- ScheduleDocument/Schedule gain optional reminderMinutes (minutes from
  midnight; decode-compatible, no schema bump)
- New Workout form gets a Reminder section (toggle + time picker, hidden
  for Now; footer warns when notifications are denied)
- ReminderPlanner (pure, tested) derives notification triggers: daily
  repeating, per-weekday repeating for fixed days, one-shot for once
- ReminderScheduler resyncs pending requests from the cache on every
  change, multiplexed onto onCacheChanged after the watch push; asks
  notification permission only once a reminder actually exists
2026-07-11 12:32:08 -04:00
rzen df07e0a043 Polish the New Workout form: segmented When on top, pushed routine list
- When section moves to the top as a segmented control (3 or 4 segments
  depending on whether Now is offered)
- Routine selection is a pushed list with each routine's symbol and color,
  checkmark on the current pick, select-and-pop
- Goal "None" reserves the symbol slot so all goal labels align
2026-07-11 11:39:29 -04:00
rzen 7cb2d6da26 Show only the day's docket on the Today board and add ad hoc Now workouts
- Due-filter schedules: daily always, fixed days by weekday, one-offs on
  their date; rest days get an empty state
- Workouts with no due schedule row (ad hoc or off-day starts) now render
  as their own board rows
- The + sheet is now "New Workout" with a "When" picker; "Now" (offered
  when adding on today) skips the schedule and starts the workout directly
- Remove the times-per-week scheduling mode everywhere (enum, document,
  entity, mappers, planner, seeds, tests)
2026-07-11 11:09:11 -04:00
rzen 9dc9283b4c Record the persistence-migration proposal (documents -> pure database)
Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
2026-07-11 08:20:39 -04:00
rzen 20d7f00843 Fix the day picker's workout-day dots missing on most days
The decorated-days set was built from dateComponents([.year, .month,
.day], from:) output, which also sets isLeapMonth = false; the day
picker probes it with bare y/m/d components (isLeapMonth nil). The two
compare == but hash differently, so Set.contains missed essentially
every member and dots appeared only on chance hash collisions. Build
the set from bare y/m/d components matching the probe's shape.

Claude-Session: https://claude.ai/code/session_012qw2itfzKyEJ1HpsFt8Ex4
2026-07-11 08:20:39 -04:00
rzen f8014e62ac Add day navigation to the Today board toolbar
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
2026-07-11 08:20:39 -04:00
rzen 9a832ad960 Rework the spoken count-in and count timed sets out
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
2026-07-11 07:53:11 -04:00
rzen 6e440317c4 Restructure into a three-tab app with Progress, goals, and Meditation
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
2026-07-11 07:53:01 -04:00
rzen 5c201289fb Survive deleted-and-saved @Models on the started-run navigation path
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
2026-07-10 11:32:52 -04:00
rzen 372b5dfb4d Record L4 device result and severing alternatives in BULLETPROOFING.md
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
2026-07-10 11:32:42 -04:00
rzen 85e15823a3 Guard NavigationLink destination inits against deleted @Models
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
2026-07-10 10:47:10 -04:00
rzen 8b20d7ad08 Preview the next exercise and count into each set during rests
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
2026-07-10 09:38:45 -04:00
rzen bce2bf7539 Record the L4 device test protocol in BULLETPROOFING.md
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
2026-07-10 06:53:13 -04:00
rzen ccd5194c6c Surface phone/watch schema mismatch on the watch; log dropped pulls
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
2026-07-09 23:12:39 -04:00
rzen 62a2882f0e Dismiss stale exercise flows on remote resolution; unfreeze the cover
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
2026-07-09 23:09:50 -04:00
rzen 25a111a6b4 Suspend watch edit locks on background; degrade oversized pushes
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
2026-07-09 23:04:19 -04:00
rzen abb223daca Make the watch own its workout-session lifecycle
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
2026-07-09 22:57:26 -04:00
rzen e6cea299dc Document device communication and bulletproofing findings
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
2026-07-09 22:51:10 -04:00
rzen c885d37d44 Remove the buried Start This Split button from the exercise editor
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
2026-07-09 22:51:10 -04:00
rzen ae3aa15019 Add generic watch-tracked Cardio exercise and starter split
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
2026-07-09 18:20:05 -04:00
rzen e12fe31152 Add Warm-Up and Stretching activity types
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.
2026-07-09 15:45:31 -04:00
rzen f01e149269 Launch the watch from the exercise-list start path too
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
2026-07-09 15:24:01 -04:00
rzen 41226545e3 Don't double-label voices whose name already carries the quality tier
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
2026-07-09 15:19:52 -04:00
rzen 90deb582fe Add per-split rest length and hands-free auto-advance flow
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.
2026-07-09 15:18:13 -04:00
rzen 0f5f11e5e2 Add spoken exercise instructions (on-device TTS)
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
2026-07-09 13:27:40 -04:00
rzen 8c8f22850e Merge watch workout pushes per log instead of whole-document
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.
2026-07-09 08:35:16 -04:00
rzen 04523c875a End the watch workout session from durable state, not a view
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.
2026-07-09 08:12:06 -04:00
rzen 9ea5e54b15 Add phone/watch scenario diagrams companion doc
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
2026-07-09 06:01:28 -04:00
rzen a8d90b638b Anchor auto-advanced phases at the boundary, not at tick time
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.
2026-07-09 05:58:32 -04:00