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
Populate the previously-empty AccentColor asset (iOS + watch) with the
logo purple — a deep shade in light mode, brightened for dark mode and
the watch's black background. The exercise Done check now uses that
accent color and the in-progress indicator reads as a neutral gray, on
both iPhone and Apple Watch.
Records recent ExerciseProgressView lifecycle/mutation events (init count, page changes, bridge updates, phase starts) and shows them as a green overlay so they can be read off a frozen screen on-device. To be reverted once the hang is diagnosed.
Times work sets count down for timed exercises (rep sets still count up), rest tint is now gray, and the dot row shows one marker per work set (dash on the active set, gap widening at the current rest). Start/Done/One More are capsule buttons with a heavier rounded label and Start is text-only; the Finished! label is gone; swiping back to Ready resets the run. Shares a timer-layout skeleton so work/rest timers match; keeps WatchKit haptics, the cancel-X toolbar, the screenshot hook, and watch-sized fonts.
- Rewrite the stale CoreData/CloudKit architecture docs to describe the
2.0 iCloud Drive document architecture (JSON source of truth + SwiftData
cache, SyncEngine/ICloudFileManager/ICloudFileMonitor, ULID document/
entity/mapper split, AppServices DI, WatchConnectivity bridge, XcodeGen/
Swift 6/iOS 26, real directory layout).
- Add an "Authoring the Changelog" section documenting the end-user,
one-paragraph-per-entry, derive-but-rewrite-from-git-log convention.
- About screen: make the version line open the changelog (IndieAbout
0.2.0 changelogDocument) and drop the separate "Changelog" link; bump
the IndieAbout dependency to from: 0.2.0.
Claude-Session: https://claude.ai/code/session_01A9CfUa4E9Zd5swfoNsYPs7
Record how CHANGELOG.md is written: end-user-facing (it renders
in-app via IndieAbout), grouped by month, one blank-line-separated
paragraph per entry with no dash markers, derived from but rewritten
off the git log, keeping only user-significant changes while
splitting multi-change commits and collapsing duplicates.
Claude-Session: https://claude.ai/code/session_01A9CfUa4E9Zd5swfoNsYPs7
Derive entries from the git log, rewritten for end users: one
blank-line-separated paragraph each (no dash markers, which the
in-app Apple inline-Markdown renderer doesn't style), keeping only
user-significant changes, splitting multi-change commits and
collapsing duplicates. Extends coverage back to the January 2026
pre-2.0 history so the CloudKit -> iCloud Drive arc is visible.
Claude-Session: https://claude.ai/code/session_01A9CfUa4E9Zd5swfoNsYPs7
Replace the placeholder LICENSE with the canonical ISC license — a permissive, MIT-equivalent license without the contradictory "all rights reserved". Formatted for the Apple inline-Markdown subset IndieAbout renders in the in-app About screen.
Claude-Session: https://claude.ai/code/session_01A9CfUa4E9Zd5swfoNsYPs7
Add Scripts/update_build_number.sh (the shared indie build script) and wire it into both targets as a postBuildScripts phase. On each build it sets CFBundleVersion to the git commit count, writes a BuildDate key (surfaced by IndieAbout), and tags the marketing version. Run as a post-build phase rather than post-compile so it lands after the Info.plist/dSYM exist, which also avoids an Xcode 26 build cycle. Bumps MARKETING_VERSION to 2.1.
Claude-Session: https://claude.ai/code/session_01A9CfUa4E9Zd5swfoNsYPs7