Skip to content

Lift Log: follow-ups from seven gym sessions - #2403

Merged
ryanbr merged 24 commits into
ryanbr:mainfrom
UtkuDenizAltiok:lift-log-follow-ups
Sep 23, 2026
Merged

ryanbr merged 24 commits into
ryanbr:mainfrom
UtkuDenizAltiok:lift-log-follow-ups

Conversation

@UtkuDenizAltiok

Copy link
Copy Markdown

What this PR does

After #2098 and #2099 were merged, I used the Lift Log in seven real gym sessions (15–23 Sep, WHOOP 5.0). This PR is what those sessions showed: fixes for what went wrong, and a few small improvements. Every one of them came from using it under a bar, not from reading the code, and each is described below in the order a lifter meets it. The commits follow the same sections.

Finishing a session

  • One Save button. Skip and Save session did the same thing, so Skip is gone.
  • A set you finished counts as done. It saves the numbers you typed, or the grey numbers if you typed nothing. The finish screen now only asks about sets you never started: complete them, or discard them.
  • Discarded sets are kept as 0 kg × 0 reps. They are left out of every figure, and you can still fill them in under Edit sets if the discard was a mistake.
  • Nothing done, nothing saved. If no set was done and the rest are discarded, no session and no workout are saved. This is your guard from Lift Log: an on-device gym log book, advanced by a double-tap on the strap #2099 (fed714cb), kept.
  • The program follows the session. At Save, each exercise in the program takes the weight and reps of its heaviest set (more weight first, then more reps). Warm-ups and discarded sets don't count. A changed set count is still asked about.

Adding an exercise during a session

  • "Add exercise" at the end of the session: pick an exercise you have done before, or type a new one and choose its muscles (a new one is saved, like a name typed in the program editor). It joins the session with one set at 0 kg × 0 reps; type what you lift, and add sets with Add set. Undo removes it.
  • The program only changes if you say so. Finish asks, in the same question as changed set counts, whether to keep it; "Update program" adds it at the end of the program with its sets and its heaviest set. Rest and max RPE are left for the program editor.

Editing a finished session

  • Add and remove sets under Edit sets. Set numbers are renumbered on Save, and only that session changes.
  • A box showing 0 clears when you tap it, so typing replaces the 0.

Max RPE

  • Each exercise in a program can have a max RPE (1–10): the hardest a set should feel. Type it in the editor, or fill the new Target max RPE column in the template. The editor refuses values outside 1–10; the import leaves them blank and warns.
  • The session shows it in grey in the RPE box, like the grey weight and reps, and a set you don't rate saves it.

Strap double-tap

  • A second double-tap within 5 seconds is ignored. The strap sometimes reports a second double-tap a few seconds after a real one (most likely a knock on the bar), which finished sets that had just started. The ignored tap gets no buzz and writes one line to the strap log.
  • The confirmation buzz comes right away. A double-tap also starts a sync, and the sync request used to reach the strap first, so the buzz waited behind it (up to 2.8 s). Now the buzz goes first.

Minimised bar and Lock Screen

  • Shows the next set ("Next: Set 2 · Lat pulldown") instead of "0 of 16 sets done".
  • The rest timer stops at 0:00 instead of counting up again.
  • Numbers you type into the current set show there too, not the grey plan.
  • More room for the text: the heart rate sits above the timer, and the icon and numbers are closer to the edges — on the Lock Screen and in the app's bar alike.
  • The app's running clocks read like the Lock Screen's ("0:45", "0:00", "1:05:00"), using NOOP's ActiveWorkoutClock.clock; before, the app said "45s" next to a Lock Screen "0:45".
  • The Dynamic Island shows the heart rate, and its clock keeps to the edge. A running timer text takes all the width it is offered, so the island stretched across most of the screen with the clock adrift in its middle, and the heart rate — which the Lock Screen banner shows — was nowhere. The clock is now sized the way the banner's already was, and the heart rate sits at the other end (the lift icon stands in until the strap reports one).
  • The Lock Screen banner is pushed for what a person would notice, not for every heart rate. In a 75-minute session it was pushed about 409 times for the moving number, each waking the widget to re-render and spending the budget iOS gives an app for updating a Live Activity — and late in that session the screen lit 5–10 seconds after a strap double-tap while the buzz stayed immediate. A heart rate alone now moves the banner only every 30 seconds and only when it changed by 2 bpm or more (about 143 pushes over the same session); everything else still pushes at once, carrying the current number. The app also stopped building a whole banner state once a second for a push it then decided not to send.
  • Every line the Lift Log writes into the strap log carries its own time. NOOP's log takes each line's clock from whoever writes it, and these lines had none, so when a tap or a light-up happened had to be inferred from the lines around it — in the one file that exists to answer that.
  • A running session does no work between taps. iOS closed NOOP for using too much CPU in the background (its crash reports: over 80% for a minute, redrawing screens). The session had a once-a-second timer that made every screen watching it redraw, even off screen, and the session sheet also redrew on every strap-log line and heartbeat. Now there is no timer: the rest warning and the rest's end are two one-shot timers, and the clocks and heart rate on screen are small views that update themselves. In the simulator, with the sheet open and nothing happening, NOOP's CPU went from 9.96 to 6.59 CPU-seconds a minute (6.29 with no session at all).
  • The session and its Lock Screen banner survive iOS closing NOOP. iOS closed NOOP in the background and relaunched it four times in one session. The saved session came back only when the first screen appeared, and the banner's first update after a relaunch found no session and ended the banner iOS had kept; iOS does not allow a new one from the background, so the Lock Screen stayed dark until NOOP was opened. The session is now picked up when the app starts, and the banner is kept.
  • A double-tap lights up the Lock Screen, so you can see where you are without unlocking. Each double-tap writes one line to the strap log saying whether it asked iOS to light the screen.
  • One banner during a session: the sync banner (Ios sync shortcut #2272) doesn't start while a session is running, the same way the heart-rate banner already steps aside.

Typing

  • One tap moves from one box to the next. Before, the first tap on the reps box only closed the keyboard.

Behind the scenes

  • A set with 0 reps counts nowhere: LiftMetrics.isPerformed, and the same rule in the SQL of liftSetCounts and lastLiftSets (reps <> 0), with a test that the two agree.
  • Android: LiftMetrics.kt (analytics(android): Kotlin LiftMetrics twin of the Swift engine (#2099) #2232) follows the change, with its oracle regenerated from the real Swift packages, and deleteLiftSets has its Kotlin twin in DeviceRegistryDao. Android has no Lift Log screens, so nothing else there changes.
  • Tools/parity_twin_map.json is refreshed with parity_ledger.py --refresh-derived for the two new twin pairs (not edited by hand).
  • Following the PERF rule TodayView already states: no Lift Log screen watches LiveState or AppModel any more; the heart rate and the clocks are leaf views (LiftLiveReadouts.swift).
  • Outside the Lift Log's own files: FrameRouter hands a double-tap on before it starts the sync; AppModel's double-tap handler type is @MainActor; SyncLiveActivityController gets a holdsBackNewBanner hook; and StrandiOSApp wires them up and resumes a saved session in its init (it used to be RootTabView's .task).
  • The exercise-name suggestions and the muscle picker move from LiftProgramItemSheet into LiftExercisePicking.swift, shared by the program editor and the new Add exercise sheet; the program editor behaves as before.
  • No schema change: an added exercise uses the existing liftExercise and liftProgramItem tables, and the crash snapshot gains one optional field.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • CI / tooling

How it was tested

  • Seven gym sessions on a WHOOP 5.0 (15–23 Sep). Checked there: one Save, discarded sets shown as 0 / 0 under Edit sets, adding and removing sets, the warning before a save that files nothing, the grey max RPE, one-tap typing, the next-set line and the timer stopping at 0:00. On 17 Sep the strap sensed 28 double-taps and all 28 reached the app. The 21 Sep session found the banner ending after iOS relaunched NOOP; the fix was checked in the simulator against ActivityKit's own log (the old build ends the banner two seconds after a relaunch; this one keeps updating the same banner). The 22 Sep session ran 1 h 39 m without iOS closing NOOP once (four kills the day before, in 28 minutes), and its log shows 34 double-taps handled, buzzed 0.26–0.74 s after the strap sensed them. The 23 Sep session added 24 more steps with no tap refused by any guard, and it also shows the reconnect path: the link timed out, a tap made as it dropped arrived 156 s later through the sync and was correctly not applied, and the session carried on.
  • Every new test was seen to fail without its fix, then pass with it.
  • swift test: WhoopStore 609, StrandAnalytics 2041, StrandImport 327 — 0 failures.
  • xcodebuild test (macOS): 2102 tests; only the two date-format TodayCarryOverTests fail, the same as on main on this machine.
  • Android CI (build + unit tests, including the regenerated oracle): green.
  • Parity with Python 3.12: ledger, ratchet and governance tests (124) all pass.
  • iOS simulator, before and after: one-tap typing, the Lock Screen timer, the banner and bar layout, adding an exercise through to the saved program (read back from the database), and the banner kept across a killed and relaunched app.
  • Both app targets build. Every new text is translated into all ten languages.

Checklist

  • Swift package tests pass for any package I touched (swift test in Packages/<name>)
  • Android unit tests pass if I touched android/ (via Android CI — no local Android SDK)
  • No new build warnings introduced — onChange(of:perform:) is used once more, in Edit sets: the single-parameter form the macOS 13 target needs, as in Lift Log: an on-device gym log book, advanced by a double-tap on the strap #2099
  • UI changes use only StrandDesign tokens
  • No hardcoded hex frame bytes; protocol facts live in the schema / decoders
  • Follows the conventions in docs/CONTRIBUTING.md
  • I did not commit generated output (Strand.xcodeproj/) or any secrets/keystores

Related issues

Follows #2098 and #2099 (merged as 4453a089, keeping its guard fed714cb) and #2232 (the Kotlin LiftMetrics twin). Touches #2272's sync banner only so it doesn't start during a gym session. Does not address #2327 (no Android UI).

🤖 Generated with Claude Code

UtkuDenizAltiok and others added 24 commits September 23, 2026 03:01
…when editing

From a second gym session on 15 Sep 2026, run on ryanbr#2099 shortly before it merged:

Finishing had two buttons, Skip and Save session, that saved the same way.
Skip is gone; session RPE stays optional.

Discarding unfinished sets now keeps them as 0 kg x 0 reps instead of dropping
them, so a discard made by mistake can be filled back in under Edit sets. A set
with zero reps was not performed: `LiftMetrics.isPerformed` leaves it out of
every figure, and `WhoopStore.liftSetCounts` and `lastLiftSets` apply the same
rule in SQL, so it never counts toward a muscle or becomes the next session's
grey numbers. This also stops a set typed as 0 reps from counting as a set,
which it did before. The two set counts stay pinned to agree on it. A set with
no rep count still counts.

The guard from ryanbr#2099 that files nothing when a discard leaves no set now reads
"no set counts", since discarded sets are no longer dropped. A session run
face-down with nothing typed and then discarded still files no session, sets or
workout, and the finish sheet now says so before Save.

Edit sets can add a set to an exercise and remove its last one. Set numbers are
renumbered on save, an added set takes the exercise's muscles and no timing,
and a removed one is deleted through the new `deleteLiftSets`. Only that session
changes, never the program. A field holding 0 empties when focused, so typing
replaces the 0 instead of appending to it. The session summary shows only
performed sets, and says so when there are none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… LiftMetrics twin

The Swift side of this change leaves a set with zero reps (how a discarded set
is saved) out of every figure. ryanbr#2232 made LiftMetrics.kt its twin, so it has to
follow: `isPerformed` is added with the same rule, `reps != 0`, and
`perExercise`, `rpeProfile` and `muscleCounts` skip such a set exactly as Swift
does. A set with no rep count still counts on both.

The oracle fixture gains a bench set at zero reps that carries muscles and an
RPE, so a port that skips the rule in `muscleCounts` or `rpeProfile` shows up
as an extra chest set or rating rather than only a missing row, and an
`isPerformed` section. The expected block is the stdout of the Swift build,
regenerated from the real StrandAnalytics and WhoopStore packages rather than a
stub; every section this change does not touch came back byte-identical to
the previous oracle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`WhoopStore.deleteLiftSets`, which editing a finished session calls when a set
is removed, was Swift-only. The parity ratchet names that as a new one-sided
declaration, and `PARITY_GOVERNANCE.md` asks for the twin rather than debt: a
disposition cannot settle `add-unpaired-function` anyway (ryanbr#2163).

`DeviceRegistryDao` already holds the lift tables' delete queries, so the
by-id delete sits with them. It is ported ahead of its consumer, as ryanbr#2232 did
for `LiftMetrics.kt`: Android has no Lift Log screen yet, so nothing calls it.
Both sides now declare the pair in their doc comments.

Measured, not assumed: with this the ratchet reports no error and the ledger no
new finding, so the branch needs no authority refresh and touches neither
`parity_twin_map.json` nor `parity_ledger_baseline.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…from the template

Asked for by Utku after his gym sessions: a lifter wants to set how hard a set
may feel at most, so they know where to hold back and avoid injury.

A program line's max RPE (1-10) is typed in the line editor or imported from
the template's new `Target max RPE` column, and stored in
`liftProgramItem.targetRpe`, which the schema already carried and nothing
filled. It is a ceiling, not effort planned in advance: the session's RPE
field shows it grey as "≤8" and the program list as "max RPE 8", and only a
typed rating is ever saved. A set done without a rating, or completed at
finish, saves no RPE, so the RPE coverage card still counts only real ratings.

Outside 1-10 is refused: the editor will not save it, and the importer leaves
the line without one and names the row in a warning. The template's column
only accepts 1 to 10, the importer also reads `Max RPE` and `RPE` headers, and
the import guide and preview show the column. No schema change; the column's
description on both platforms now says what it holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Utku, 16 Sep 2026: grey should mean the same thing in every column. Weight and
reps already save their grey number when the field is left empty, so RPE now
does too — the line's max RPE, which is what the session showed grey.

A rating typed for the set still wins, a discarded set still saves zeros and no
rating, and a previous set's own rating is never copied onto another set: only
the plan's own number fills a blank. The grey RPE is drawn as a plain number
rather than "≤8", because grey now means "this is what saves".

The cost is stated rather than hidden: a stored rating no longer proves the
lifter rated that set, so the session's RPE card can report the plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tapping the reps field with the keyboard open in the weight field took two
taps on the phone: the screen-wide tap gesture that puts the keyboard away
also fired for a tap that landed in another field, and took the focus the
field had just been given (gym session, 16 Sep 2026).

The watcher is now a UIKit tap recognizer on the window that reads the
touched view: it stands aside for any text input and for touches outside
its own screen, and still never swallows a tap, so a button pressed with
the keyboard open both dismisses it and acts.

Simulator, both builds: with the old gesture one tap on REPS closed the
keyboard and the typed digit went nowhere; with this one the cursor moved
and the digit landed. A tap on empty space still dismisses, and a tap on
"Start first set" dismisses and starts the set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… kicks

Two things the strap log of the 16 Sep 2026 gym session showed.

A knock read as a tap. The strap's own sensor log recorded two double-taps
3 s and 4 s after one that had just started a set. Both were genuine
detections with their own timestamps, so FrameRouter's de-duplication let
them through, and each finished a set seconds old and started its rest
("it skipped two things"). The session now holds back a strap double-tap
that comes less than 8 s after the last one it acted on, unless a rest that
is already over is waiting to be ended (a line planned with no rest). The
held-back tap gets no buzz, the lifter's cue to tap again, and leaves a
strap-log line. The on-screen button is never held back.

The confirming buzz queued behind a sync. A DOUBLE_TAP event also kicks a
rate-limited sync, and FrameRouter did that before handing the tap on; the
session then hopped through a Task before buzzing. When the sync was not
rate-limited, the strap received "send historical data" first and started
the transfer before playing the buzz: those four taps buzzed 1.0-2.8 s after
the strap sensed them, where most others came in under one second.
FrameRouter now hands the tap on first and the session buzzes synchronously,
so the buzz is written ahead of the sync request. Wrist and other events
keep their order. Android has no Lift Log and is unchanged.

For the part of that session the exported log still holds (21:54-22:26; the
earlier half hour had rolled out of the 5,000-line buffer), every double-tap
the strap's sensor reported (22) was acted on and buzzed. A tap the sensor
never reports cannot be recovered by the app.

Tests seen to fail without each change: the knock test, the synchronous-buzz
test, and the order test in FrameRouterDoubleTapDedupTests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…stops at 0:00, and a Lock Screen that lights on a strap step

From the 16 Sep 2026 gym session.

- The next set replaces "3 of 19 sets done" on the minimised bar and the
  Lock Screen: "Next: Set 2 · Lat pulldown", on one line, always a set and
  never the rest before it. It is where the taps actually go
  (`LiftSessionEngine.upcomingSlot` is `slotAfter` with the current set
  counted as done), so the last set of an exercise names the next exercise;
  "Last set" while the final set is worked, "All sets done" once it is. The
  set number comes before the name, so a narrow line cuts the name. The
  bar gains it as a third line; the Lock Screen's third line drops the
  count and the program name for it. The warm-up now reads "Warm-up".
- The Lock Screen rest clock stays at 0:00 once the rest is over, as the
  in-app bar does. It used to count up again, because a widget re-rendered
  after the end (for the "Ready for the next set" push) switched to a
  count-up; the countdown's range now starts at the rest's start.
- Numbers typed into the set being lifted show on the bar and the Lock
  Screen. Found in the simulator walkthrough: 70 kg x 9 typed into the
  running set, and the bar said the grey "8 x 60 kg".
- A strap double-tap that moves the session lights the Lock Screen: the push
  it triggers carries an ActivityKit alert, skipped while the app is on
  screen. ActivityKit offers an alert only the default sound or a named
  file, so it names a bundled 0.2 s of silence; the strap has already
  buzzed. Whether the phone also vibrates is up to iOS, and is to be seen
  on the phone.

Simulator: the bar and the Lock Screen show the next-set line; after a rest
ended and the "Ready" push re-rendered the Lock Screen, the new clock read
0:00 where a build with the old clock read 2:-- and climbing. The light-up
cannot be seen in the simulator. New strings in all ten locales. Tests seen
to fail without the change: the two next-set engine tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…p sends at once

Utku, 17 Sep 2026: the Lock Screen should just light up from black when a
double-tap moves the session, then go dark on the phone's own timer —
nothing else, and no delay.

- Only a locked phone is lit: the alert is skipped when protected data is
  available (unlocked), so using another app never pops the Dynamic Island.
  Skipped in the app as before.
- The signal is sent straight after the stage moves, before the pending-input
  and persistence bookkeeping, so the lit update is the first update the step
  causes and usually the only one; what follows cannot change what the banner
  shows.
- ActivityKit has no vibration setting for an alert; the sound stays a
  bundled silence.

Test seen to fail without the signal: the strap step lights once with the new
stage in place; a held-back knock and the on-screen button light nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…am name the banner no longer shows

The minimised bar asked the controller for the session's presentation three
times on every one-second tick (title, status line, next set); it now reads
it once. The Lock Screen banner stopped showing the program name when the
next set took its line, but the name was still carried in the activity's
attributes and threaded through the controller; both are gone.

Simulator: the bar still shows exercise, status and next set; the Lock
Screen banner is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Utku, after the 17 Sep gym session: 8 s was too long to wait for a
deliberate second double-tap. In that session's log the window held back
two taps at +3.5 s and +6.0 s after an acted-on one; both read to him as
"unregistered". 5 s still covers the knocks measured on 16 Sep (+2.9 s and
+4.1 s). A test pins that 6 s is a tap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… line's heaviest set

Utku, 21 Sep 2026, after the fourth gym session.

- A set ticked by Set done or a strap double-tap is complete, with no
  question at finish: it saves what was typed into it, and a field left
  blank takes the grey value the sheet showed. Finishing asks "complete or
  discard" only about sets never started ("Sets not started: N"), and
  "Discard them" turns only those into 0 x 0. `LiftSessionEngine.
  unperformedSlots` replaces `unenteredSlots`; a session with no set done
  and the rest discarded still files nothing (the ryanbr#2099 guard).
- At Save each program line takes the weight and reps of its heaviest set
  done this session (more weight first, then more reps), without asking —
  his choice of the heaviest over the last or first set, so a lighter
  back-off set does not pull the working weight down. Warm-ups, discarded
  zeros and sets completed at finish without being started move nothing; a
  bodyweight set keeps the line's weight; a leftover rep-range top below the
  new count is dropped. Set counts changed with plus/minus are still asked
  about, and both go to the program in one write, only when a line differs.

Tests seen to fail without the change: done sets complete without asking,
only never-started sets unfinished or zeroed, a discarded set takes no max
RPE, the heaviest-set rule and its exclusions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…og each strap step's light-up

Utku, 17 Sep gym session: four strap steps buzzed and moved the session
but did not light the Lock Screen — "sometimes it lights up and sometimes
not". The alert waited for the phone to report itself locked (protected data
unavailable), and iOS reports that only about 10 s after the screen goes
dark, so a tap soon after it dimmed — right after checking the rest timer —
lit nothing. The alert now goes whenever NOOP is not the app on screen; with
another app open iOS shows the step in the Dynamic Island instead.

The log could not say which steps were asked to light, so each strap step
now leaves one line in NOOP's strap log: "sent to the Lock Screen with a
light-up alert", or not lit because NOOP was on screen or no Lift Log banner
was running. A step whose alert was sent but did not light was iOS's own
choice (a face-down phone, a Focus, its limits) — which the next log can now
tell apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…no sync banner

Upstream's strap-sync Live Activity (ryanbr#2272) starts whenever a sync begins
with NOOP on screen. During a Lift Log session that is often: opening NOOP
mid-session, or a strap double-tap while it is open, kicks a sync, and a
second banner would sit beside the session's own. NOOP already stands its
live-HR banner aside for the session; the sync banner now does the same for
starting (`SyncLiveActivityController.holdsBackNewBanner`, set by the app for
the length of a session). A banner the Sync Strap shortcut started still
updates and ends as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rt rate over the clock

Utku, 21 Sep 2026, with a screenshot: the exercise name, status and next
set were cut too soon while the icon, heart rate and clock took a third of
the banner. At the same sizes, the banner now uses less padding at its
edges and stacks the heart rate over the clock, flush right, which gives the
words back roughly half that column. A running `Text(timerInterval:)` takes
all the width it is offered — a first try spread a working set's count-up
across the banner — so the clock's width comes from a hidden "00:00" in the
same font and the live clock is right-aligned over it.

Simulator: a working set's count-up, a rest's countdown and the finished
rest's 0:00 all sit under the heart rate; "Resting after set 4 — 9 x 60 kg"
now shows whole where the screenshot cut it at "Resting after set 1...".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…d of ending it

Utku's gym session of 21 Sep 2026: the Lock Screen lit on every strap step at
first, then stayed dark "most of the time" while the buzzes and the session
carried on. His strap log shows iOS closing NOOP in the background and
relaunching it four times in 28 minutes (20:34:58, 20:42:52, 20:58:46,
21:02:20), and every strap step after a background relaunch logging "no Lift
Log banner is running" until he opened NOOP.

The cause was ours. The saved session came back only when the first screen
appeared, but the banner is driven from the app's root view, whose publishers
fire as soon as it is built. That first push found no session and ended the
banner iOS had kept alive through the restart; the next push asked for a new
one, which iOS refuses to an app that is not on screen. Traced in the
simulator from ActivityKit's own log: the gym build ends the surviving banner
two seconds after a relaunch (then, being on screen there, starts another),
while with this change the same banner keeps receiving updates.

- `LiftSessionController.resumeSaved` picks the session up in
  `StrandiOSApp.init`, before any view or publisher exists, and logs
  "Lift Log: session picked up again after NOOP restarted".
- `LiftLiveActivityController` logs when it picks up a banner after a restart,
  lets go of one the lifter swiped away or iOS ended, and no longer asks iOS
  for a new banner from the background, where it always throws (several times
  a second); it logs once that the banner returns when NOOP is next opened.
- RootTabView's resume is gone; the resume test is in
  LiftSessionPersistenceTests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Utku, 21 Sep 2026, with a screenshot of the in-app bar: apply the Lock Screen
banner's layout here too — heart rate over the timer, both at the right, and
the icon nearer the left edge, so the words get more room. Same sizes as
before. The check button stays at the far right. The clock's width comes from
a hidden "00:00" in its font, as on the Lock Screen, so the words do not shift
when the clock gains or loses a digit.

Simulator: "Ready for the next set — 9 x 60 kg" and "Set 1 — 9 x 70 kg" show
whole, with the heart rate right-aligned over the clock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Utku, 21 Sep 2026: during a running session, add an exercise the program
does not have — one done before, picked from his saved exercises, or a new
one, which is saved. It asks only for the muscles (primary and secondary);
sets, rest and max RPE are set later in the program editor.

- "Add exercise" at the end of the session sheet opens
  `LiftSessionExerciseSheet`: the exercise name with the user's own
  "Used before" names (picking one brings its muscles), and the muscle picker.
  The name is remembered in `liftExercise` like one typed in the program editor.
- The exercise joins at the end of the sheet as ONE set planned at 0 kg x 0
  reps, no max RPE (0 is not on the 1-10 scale) and the default rest; its row
  shows zeros in grey until numbers are typed, or last session's numbers when
  the exercise was done before. Add set / remove set work as on any line, and
  Undo removes it. `LiftSessionEngine.addExercise` (bounded at 200 lines, the
  importer's per-program cap), `LiftSessionController.addExercise`.
- Finishing asks, in the existing Program question, whether the program keeps
  the new exercises ("New: Pec deck · sets: 2") along with any changed set
  counts. "Update program" appends each as a line with the session's set count
  and its heaviest done set, else 0 x 0 (`programAfterSession`, which
  `LiftSessionView.writeProgram` now calls).
- The added line and its future program-line id survive a relaunch in the
  crash snapshot (optional field: a plain update, no wipe).
- The name suggestions, remembering a name, and the muscle picker move out of
  `LiftProgramItemSheet` into `LiftExercisePicking.swift`, shared by both
  pickers, with no change to the program editor.
- Six new strings, all ten languages.

Simulator: added "Pec deck" (Chest, Front delts) mid-session, did a set at
22.5 x 12, added a second set, discarded it at finish and chose Update
program. The store then held the new program line (2 sets, 12 x 22.5 kg, no
rest or max RPE), the remembered exercise with its muscles, and the session's
sets (the discarded one as 0 x 0); the week's card counted Chest 1, Front
delts 0.5. No schema change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bar, the session sheet's clocks and the rest band said "45s" and "0s"
while the Lock Screen banner showed the same clock as "0:45" and "0:00" —
two readouts of one fact in two formats, and "0:00 on every surface" was
only true of the Lock Screen. They now use NOOP's own running-clock format,
`ActiveWorkoutClock.clock` (the Live and Today workout clocks'): "0:45",
"0:00", and "1:05:00" past an hour, as the Lock Screen's
`Text(timerInterval:)` does, where `LiftFormat.duration` had no hours and
read "75:23". `LiftFormat.duration` stays for a rest spoken about: a
program line's "45s rest", a finished set's measured rest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Utku's crash reports of 21 Sep: iOS killed NOOP for background CPU three
times in that evening's gym session (20:34, 20:42, 20:58) and once that
morning (09:17, the previous build) — "48 seconds cpu time over 49-60
seconds, exceeding limit of 80% cpu over 60 seconds", NOOP not on screen,
its main thread busy redrawing SwiftUI views and laying out text. Every
kill cost the log before it and, until e4e391f, the Lock Screen banner.

The Lift Log was redrawing far more than it showed:
- the session published a tick every second to every screen watching
  it: the whole tab shell (RootTabView), the session sheet, the bar and
  the Lift Log hub — on screen or not;
- the session sheet (a hundred text fields) also watched LiveState, which
  it never read and which changes with every strap-log line, heartbeat
  and R-R packet, and AppModel for one number.
Upstream already rules this out for Today (the PERF note on TodayView:
only small leaf views may watch per-second state); the Lift Log broke it.

Now:
- no tick. A rest's two moments — the warning buzz 5 s before its end,
  and its end — are one-shot timers set when the rest starts and
  replaced when it changes (`scheduleRestTimers`, `restEventTimes`);
- the running clocks and the heart rate are small self-updating views
  (`LiftLiveReadouts.swift`: a TimelineView per clock, `LiftHeartRate`
  alone watching AppModel), so a tick or a beat redraws one number;
- the sheet no longer watches LiveState or AppModel;
- the Lock Screen banner follows `changesSettled` (each change once it
  has landed; `$engine` fired before the change, so its pushes showed
  the step before), plus a push when NOOP comes on screen.

Measured in the simulator, 60 s idle, no strap (so no heartbeat or log
traffic, which the sheet also redrew on before): NOOP alone 6.29 CPU-s;
a session with the bar 8.35 -> 6.14; with the sheet open 9.96 -> 6.59.
LiftSessionTimingTests pins it: nothing is published between taps, a
rest's end is published once and its warning buzzes once, an undone rest
fires nothing — each seen to fail with the tick restored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s to the edge

Utku's sixth gym session (22 Sep): the island's clock sat adrift in the
middle with blank to its right, and the heart rate — which the Lock Screen
banner shows — was nowhere.

Both come from the compact regions. A running Text(timerInterval:) takes
every point it is offered, so the trailing region stretched the island and
left the digits floating in it; the Lock Screen already solves this with a
hidden "00:00" in the same font and the live clock right-aligned over it,
and the island now does the same. Leading carries the heart rate (the
dumbbell stands in until the strap reports one), which is what the space
was for.

Simulator, same session and a heart rate of 128: before, the pill spans
most of the screen with "0:10" in its middle; after, it is a third
narrower with the heart rate at the left edge and the clock at the right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… 10 s

Utku's sixth gym session (22 Sep): after 20:42 the Lock Screen lit 5-10
seconds after a strap double-tap, while the buzz stayed immediate; it
recovered by 21:00. The strap log shows every step's alert leaving the app
at once (tap to buzz 0.26-0.74 s all night), so the wait was on iOS's side
of the call, and the app's own contribution to that is how much of the
activity budget it spends on nothing: the banner was pushed for the live
heart rate every 10 s, about 409 pushes across his session, each waking
the widget extension to re-render.

Now a heart rate alone moves the banner only when it has changed by at
least 2 bpm and at most every 30 s (5 s for the strap appearing or
disappearing, which is a change of state rather than a moving number):
about 143 pushes over the same session. Everything a person would notice
still pushes at once, carrying the current heart rate, and the clocks tick
client-side as before.

The per-second work goes too. StrandiOSApp's live-HR closure called
pushLiftActivity() on every tick, building a whole presentation (localized
strings, formatted numbers) and a content state before the controller
decided not to send it. It now calls updateHeartRate, which touches
nothing unless the policy says the number is worth a push.

LiftBannerPushPolicy is pure and lives in Strand/Data so StrandTests
covers it, since no CI compiles the app targets. Five tests; removing the
interval or the presence floor each fails them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
NOOP's strap log takes each line's time from whoever writes it, and the
Lift Log's lines never added one: all 98 in Utku's 22 Sep session — every
double-tap handed over, every light-up alert, every session or banner
picked up after a restart — sat between stamped lines with no clock of
their own, so when a step happened had to be inferred from its
neighbours. That is the one question a gym log is read for.

AppModel.stamped() puts the same HH:mm:ss prefix BLEManager uses in front
of them, at the five places the Lift Log writes: the double-tap dispatch
and its debounce line, the two late/duplicate gesture lines in
FrameRouter, and the session, banner and light-up lines routed through
StrandiOSApp's log closures. Nothing else about the log changes -- not
its content, its rate or its buffer.

Two tests; removing the prefix fails both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Generated with `parity_ledger.py --refresh-derived --base upstream/main`,
never edited by hand. The branch's own refresh commit was written against
a56840b; upstream has since merged the diagnostics work and migrated the
authority itself (751fa1d), so the pairs this branch adds - LiftMetrics'
isPerformed and deleteLiftSets - are re-derived on top of that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
UtkuDenizAltiok added a commit to UtkuDenizAltiok/noop that referenced this pull request Sep 23, 2026
…ssions); rule 48: a session survives the strap going out of range
@ryanbr
ryanbr merged commit 3ada90c into ryanbr:main Sep 23, 2026
19 checks passed
@UtkuDenizAltiok
UtkuDenizAltiok deleted the lift-log-follow-ups branch September 23, 2026 01:49
UtkuDenizAltiok added a commit to UtkuDenizAltiok/noop that referenced this pull request Sep 23, 2026
ryanbr pushed a commit that referenced this pull request Sep 23, 2026
… refusal (#2402)

standardHRHostReceivedLine was written for every standard-HR sample, which a
streaming strap produces once a second: 3,009 of one 75-minute session's 5,704
lines, 52.8% of everything the log had to say about it. Since the on-disk log is
held within 2 MB, that ratio decides how much history a bug report carries,
about three hours of a streaming strap where a summarised stream carries a day.

What the line exists for is kept. A refusal, an HR outside 30-220 or an R-R
outside 250-3000 ms, is still written the moment it happens. The routine samples
are counted and rendered once a minute as one line carrying how many arrived,
over how long, the widest gap between two of them, what was accepted and
refused, and what is still pending. The window is also written at a disconnect,
a background flush or a termination, so the last minute of a session does not
leave with the process, and full per-sample detail returns while the Test
Centre's HRV or Connection mode is on.

Replayed over that session's real log, 3,938 host-received lines become 67.

LivePersistTrace.StandardHRHostReceivedTrace holds the window and the rules, in
StrandAnalytics so CI compiles and tests it; Collector and StandardHrSource each
record into their own and close it when the stream ends; BLEManager and
SourceCoordinator wire the detail gate on both platforms. The Kotlin twin is
line-for-line the same window, rules and rendered text.

Rebased onto main after #2403 and the twin map re-derived with
--refresh-derived against the new authority rather than merged by hand: pairs
182, file pairs 70, nothing added to unpaired debt. Ledger, ratchet and the
parity-governance job all pass on the rebased head.

Reviewed. One note left for follow-up: gapMaxSec cannot report a stall longer
than the window, because a gap past 60 s closes the window on the next sample
and so falls between two of them, appearing in neither.

Refs #1767, #2386

🤖 Generated with [Claude Code](https://claude.com/claude-code)
ryanbr added a commit that referenced this pull request Sep 23, 2026
…oo (#2404)

The tap recognizer that decides whether a tap landed in a text field lives on
the WINDOW, which outlives the view that added it, so it has to be removed by
hand. dismantleUIView and didMoveToWindow cover the teardowns SwiftUI tells us
about; nothing covered the ones it does not.

A left-behind recognizer is quiet rather than harmful, since UIGestureRecognizer
holds its target weakly and nothing fires. But one accumulates per appearance,
and once the Probe is gone its delegate is nil, so the filter that makes the
recognizer safe (ignore touches that land in a text input, ignore touches
outside this screen's controller) is no longer applied to it.

From reviewing #2403, which added the watcher.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants