A gear on the watch root opens a small Settings sheet whose stepper
edits the shared restSeconds default (10-180s, 1s steps). Edits ride a
new settingsUpdate message to the phone - debounced per stepper burst,
falling back to transferUserInfo when unreachable - which clamps the
value, writes the shared default, and re-echoes it to every device
through the application context. While an edit is pending on the watch,
an in-flight context's stale rest value is skipped so it can't yank the
stepper back mid-edit.
Replaces the small target-HR pill on the iPhone run screen with a
glanceable big-digit strip between the timer flow and the figure —
heart rate (band-tinted with the cue arrow when the run carries a
target), HR zone, active calories, and the workout stopwatch. A
horizontal band in portrait, a vertical column in landscape (the
inverse of the half-and-half split, so it always sits between them).
The watch now rides the running calorie total and the HR zone (1-5,
computed watch-side where max HR is known) along with each live HR
sample; absent keys keep older builds wire-compatible both ways.
LiveRunState holds all three under the shared staleness expiry. The
screenshot rig seeds believable values so the run capture shows the
panel populated.
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
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
- 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
- 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)
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
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
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.
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.
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.
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.
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
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
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
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
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.
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.
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.
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
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
Watch root lists every in-progress workout; picking an exercise runs a paged Ready -> work/rest -> Finish flow (One More + auto-firing Done), with a phase-dot row and brand-tinted count-up/down timers. Includes the configurable rest and auto-finish settings synced over WatchConnectivity and the wrist-down timer fix.
DEBUG-only screenshot support (seeded sample data via ScreenshotSeed, per-screen
launch args, screenshot roots for both apps) so iPhone + Apple Watch App Store
shots can be captured deterministically from the simulator — all excluded from
release builds. Also seed ExerciseView's set-grid progress in init so it renders
correctly on the first frame. Adds Scripts/metadata/ as the listing source of
truth (copy, URLs, review notes, and the captured screenshots).
Claude-Session: https://claude.ai/code/session_018gg69MaUetDNzWzBXisfMV
- Redesign the watch app into an active-workout runner: a root gate shows the
in-progress workout's exercises or prompts to start one on iPhone, and each
exercise runs as a horizontally-paged HIIT cycle (count-up work, count-down
rest with final-three-second haptics + auto-advance, One More / Done on the
last set). Replaces the old history list.
- Add a configurable rest-between-sets duration in iPhone Settings (default 45s),
synced to the watch over WatchConnectivity.
- Launch the watch app into the session when a workout starts on the phone via
HealthKit (startWatchApp); the watch runs an HKWorkoutSession for foreground
runtime and ends it when the workout finishes. Adds the HealthKit entitlement +
Health usage strings on both targets and WKBackgroundModes on the watch.
Claude-Session: https://claude.ai/code/session_018gg69MaUetDNzWzBXisfMV
Replace Core Data + NSPersistentCloudKitContainer + App-Group store +
WatchConnectivity dictionary sync with the QuickRabbit iCloud-documents
architecture:
- iCloud Drive JSON documents are the sole source of truth (one file per
aggregate: Splits/<ULID>.json, Workouts/YYYY/MM/<ULID>.json), with a
rebuildable SwiftData cache populated only by an NSMetadataQuery observer
and a connect-time reconcile. Soft-delete tombstones; hard iCloud gate.
- Shared model layer (ULID, Codable *Documents + stateless mappers, @Model
cache entities, SwiftData container) compiled into both targets.
- New iPhone<->Watch bridge over WatchConnectivity keyed by ULIDs; the phone
is the sole writer of iCloud Drive, the watch round-trips documents.
- AppServices DI + iCloud-required root gate; Swift 6 strict concurrency.
- Starter splits generated on demand from the bundled YAML catalogs.
- Migrate to XcodeGen (project.yml), iOS 26 / watchOS 26; CloudDocuments
entitlement (drop CloudKit/App Group/aps-environment).
- Duration stored as Int seconds (was a Date epoch hack); fix workout
end-on-create, undismissable delete dialog, toolbar-hiding nav stacks,
and the Settings placeholder.
- Add README/CHANGELOG/LICENSE, .gitignore, refreshed REQUIREMENTS, and the
Scripts/ TestFlight pipeline (release.sh + ASC API scripts).
MARKETING_VERSION 2.0.