Lift Log: follow-ups from seven gym sessions - #2403
Merged
Merged
Conversation
…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
12 tasks
UtkuDenizAltiok
added a commit
to UtkuDenizAltiok/noop
that referenced
this pull request
Sep 23, 2026
…nbr#2099 answered; ryanbr#2402 still open
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)
This was referenced Sep 23, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
fed714cb), kept.Adding an exercise during a session
Editing a finished session
Max RPE
Target max RPEcolumn in the template. The editor refuses values outside 1–10; the import leaves them blank and warns.Strap double-tap
Minimised bar and Lock Screen
ActiveWorkoutClock.clock; before, the app said "45s" next to a Lock Screen "0:45".Typing
Behind the scenes
LiftMetrics.isPerformed, and the same rule in the SQL ofliftSetCountsandlastLiftSets(reps <> 0), with a test that the two agree.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, anddeleteLiftSetshas its Kotlin twin inDeviceRegistryDao. Android has no Lift Log screens, so nothing else there changes.Tools/parity_twin_map.jsonis refreshed withparity_ledger.py --refresh-derivedfor the two new twin pairs (not edited by hand).LiveStateorAppModelany more; the heart rate and the clocks are leaf views (LiftLiveReadouts.swift).FrameRouterhands a double-tap on before it starts the sync;AppModel's double-tap handler type is@MainActor;SyncLiveActivityControllergets aholdsBackNewBannerhook; andStrandiOSAppwires them up and resumes a saved session in itsinit(it used to beRootTabView's.task).LiftProgramItemSheetintoLiftExercisePicking.swift, shared by the program editor and the new Add exercise sheet; the program editor behaves as before.liftExerciseandliftProgramItemtables, and the crash snapshot gains one optional field.Type of change
How it was tested
swift test: WhoopStore 609, StrandAnalytics 2041, StrandImport 327 — 0 failures.xcodebuild test(macOS): 2102 tests; only the two date-formatTodayCarryOverTestsfail, the same as onmainon this machine.Checklist
swift testinPackages/<name>)android/(via Android CI — no local Android SDK)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 #2099StrandDesigntokensdocs/CONTRIBUTING.mdStrand.xcodeproj/) or any secrets/keystoresRelated issues
Follows #2098 and #2099 (merged as
4453a089, keeping its guardfed714cb) and #2232 (the KotlinLiftMetricstwin). 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