Lift Log: an on-device gym log book, advanced by a double-tap on the strap - #2099
Conversation
ryanbr#2098 (the v46 schema plus its Room twin) and ryanbr#2099 (the app, depending on it). Section 11 rewritten as the post-submission state: do not touch either branch while review is pending, and note that upstream CI compiles both app targets where the fork's does not. Records the Android position as landed — the twin is IN ryanbr#2098 and Android CI verifies it, so parity is closed by the oracle rather than by argument — and one claim that was checked and turned out FALSE: the twin does NOT give backup parity, because Android explicitly rejects a Mac/iOS .noopbak. It nearly went into the PR as a benefit.
|
Thanks @UtkuDenizAltiok. Reviewed against the code. The hardware evidence is what makes this reviewable at all: four sessions, with three of the findings being silent wrong data rather than anything that looked broken, is the right way to have built it. Refusing a composite score, and saving One real defect in the BLE change, and two smaller notes. The double-tap de-duplication only suppresses consecutive duplicates
Two phantom advances and two lost sets, which is the failure this change exists to prevent. Your own note of "one ambiguous occurrence in the first session" is consistent with this being the residual rather than noise. The tests do not reach it. A bounded set of dispatched timestamps, pruned by LiftMetrics has no Kotlin twin
Not a blocker for an iOS-only feature, but worth stating in the PR as deliberate, the way the missing screens already are. The workflow changeShipping the template beside the builds is sensible, and it is correctly non-fatal. It is a second concern inside a feature PR though, and it edits the testing-build workflow on behalf of a feature that is not on the default branch yet. Order#2098 wants its five Android delete methods first. Once this one widens the de-duplication, both are in good shape. |
f848c8e to
8a3d775
Compare
|
Thanks for the review — all three addressed. Double-tap de-duplication. You were right, and it was worse than the trace: with the single slot, two taps replayed together dispatched four times, and three taps across a re-walked offload dispatched twelve. It now keeps a set of dispatched timestamps, pruned to the live window with a hard cap; failing open on a missing timestamp is unchanged. Three tests added, including an interleaved replay built from a second gesture minted with a recomputed CRC32 —
The workflow change. Dropped from this PR. |
|
@ryanbr Hi, I really believe that this feature will be quite useful to all users that lift in the gym. I tried my best to perfectize it with the actual real life tests. I am willing to cooperate to make this public asap because I would like to have this in the main branch instead of my private fork. Since you did not reply this one but replied newer PRs I would like to do updates. |
65f6a59 to
bb6b61d
Compare
Records that the red checks on ryanbr#2099 were jobs GitHub never started, the identical-tree re-stamp to bb6b61d, the local merged-tree iOS build that proves the merge compiles, and the restored release. Lesson kept: a clean trial merge is not a clean trial build once upstream has moved.
bb6b61d to
d0bd179
Compare
… real gate Re-stamped once runners recovered (d0bd179, identical tree) and edited the PR comment in place. Records that CONTRIBUTING.md wrongly calls app-build.yml disabled — it is active, builds the PR merged into main, and gates merge state.
Each thread now reads review, answer, nudge. The parity-ledger finding moved into ryanbr#2099's description before its comment was deleted.
By @UtkuDenizAltiok. Storage only: five deviceId-keyed tables for saved programs and the sessions run from them, with no UI and nothing feeding a score. The app that uses them is #2099. Checked against the code rather than the description, twice: once when this was opened and again before taking it. All seven version pins agree. v46-lift-log is registered after v45, Room is at @database(version = 40), SCHEMA_VERSION is 40, and roomVersion is 40 in both schema_oracle.json copies, which are byte-identical. Every table and index is created IF NOT EXISTS, the natural keys are unique indexes, and LiftMuscle is a closed 20-token vocabulary pinned as a stored-data contract. The gap found on the first pass is closed. DeviceRegistryDao had not moved with deviceScopedTables, so a forgotten device would have left its lift rows behind. All five tables now carry both a delete and a #771 re-key, and both are wired into the fan-out rather than only declared. The hazard particular to a schema PR that sits for ninety commits is a competing migration or an oracle edited underneath it. Neither happened: nothing on main has touched Database.swift, WhoopDatabase.kt or either oracle copy since this branched, so the version it pins is still the version it lands on. No load or strain column, deliberately. Strain stays HR-derived and a lift session saves as a workout with strain nil, matching the existing imported lifting path. liftSet snapshots its muscle classification at log time, so reclassifying later cannot rewrite past weeks. WhoopStore 575 tests, 0 failures. Android CI green, including SchemaOracleTest against the shared fixture and the DeviceRegistry delete fan-out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
#2098 is in, as 8818477. Sorry for the wait on it, and thank you for the nudge; it was ready and I had not come back to it. This one needs the rebase you offered before I can take it. Not a judgement on the change, a mechanical consequence of how #2098 landed. #2098 was squash-merged, so its four commits are not ancestors of main. Your branch still carries them, and the merge base is still where you branched, which means git sees #2098's schema as new work arriving again. I ran the merge locally to be sure rather than guess: and with those set aside, A On the change itselfI have read it rather than only the description, and the part I went looking for is sound. The The reasoning for a set rather than a single slot, with the interleaved A/B walkthrough, is the kind of thing that usually gets discovered later on a wrist. Good that it is written down and pinned by a test. What I will want before it goes inNothing new, just the two you already named as untested:
You said you would update that line after the next session. That is the line I will read before merging, because three of your four gym sessions each turned up something and all three were silent wrong data rather than anything visible. That record is an argument for one more session, not against your testing. The unticked token box is fine. Spacing values without a matching token exist elsewhere and I am not going to hold a feature for it. Rebase when you have a moment and I will pick it up. |
Phase one of the Lift Log UI: build a program once and reuse it. Adds a
"Lift Log" row to More → Body, next to Workouts, on the v40 schema.
Screens:
LiftLogView the hub — saved programs, and a header stating
plainly that lifting never moves Effort
LiftProgramEditorSheet name, note and an ordered list of exercise lines
LiftProgramItemSheet one line: the exercise, its muscles, and the targets
NOOP still ships no exercise catalogue. The user types whatever they call a
movement; it is remembered in `liftExercise` with the muscle group they gave
it and offered back next time, with its classification adopted automatically.
A shipped exercise→muscle mapping would be both a permanent maintenance
burden and a correctness claim NOOP has no business making about someone
else's technique.
Classification is a one-time action per exercise: one primary muscle (a direct
set) and any number of secondaries (indirect, counted at half). Selecting a
primary removes it from the secondary list, so one muscle can never be
credited twice for the same set.
Weight display units are the app's EXISTING metric/imperial preference
(`UnitPrefs.systemKey`) rather than a second setting of the Lift Log's own —
a pounds user gets pounds for free, and two settings can never disagree.
`LiftFormat` converts through one constant in both directions so a typed
weight round-trips instead of drifting a tenth on the way back.
All 30 new strings are translated into the nine shipped locales. `Effort` uses
the catalog's existing per-locale metric name (Belastung / Esfuerzo / Sforzo /
Wysiłek / Усилие / 消耗 …) rather than a fresh translation, so the screen agrees
with the rest of the app.
Verification: `xcodebuild` clean for BOTH targets — NOOPiOS (iOS Simulator)
and Strand (macOS) — since files under Strand/ compile into both and no CI
covers either. `Tools/i18n_audit.py --ci main` and `Tools/doc_comment_lint.py`
both pass. Exercised end-to-end in the iPhone 17 Pro simulator: created a
program, typed a new exercise, classified it Chest + front delts/triceps, set
targets, saved, reopened — name, line, targets and classification all restored
from SQLite, and the primary correctly absent from the secondary list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase two: run a program at the gym. Warm-up → set → rest → set → … →
cool-down → save, one action per transition, and the finished session lands in
the workout table like any other workout.
THREE WAYS TO ADVANCE, all identical:
• a double-tap on the WHOOP strap — the one cue that works with the phone
face-down on a bench;
• a tap anywhere on the screen;
• the explicit button.
The last two were asked for by name. They are ordinary single taps: the
double-tap is the STRAP gesture only, because a strap takes knocks against
bars all session while a phone screen in your hand does not.
`LiftSessionEngine` is the state machine, kept pure — no timers, no store, no
SwiftUI — because it is the piece most likely to be wrong in a way that costs
someone a logged set, and the only part of the feature testable without a
strap, a database or a simulator. Time enters as a parameter, so a whole
session can be driven through a known timeline. 17 tests.
Rest is anchored to an ABSOLUTE instant, never a decrementing counter:
`IntervalTimerView` decrements and loses time when the phone suspends, and a
rest timer that quietly runs long is worse than none. Verified by killing the
app mid-rest — it read 1:55 at death and 1:00 on resume, having kept counting
while the process was gone.
Rest never auto-advances. When the countdown reaches zero the stage stays
resting and waits: a timer that starts logging while you are still racking the
bar attributes time to work that was not work.
The strap buzz fires five seconds before the rest ends, gated behind a new
`HapticPrefs.liftRest` key rather than sharing `intervals` — a silent interval
timer at home is a different want from a silent rack. A running session claims
`AppModel.strapDoubleTapOverride` for its lifetime and hands the gesture back
untouched when it ends.
`LiftSessionPersistence` writes the in-flight session on every tap, so a crash,
a call or a flat battery costs nothing. The stage is persisted as a flat record
rather than an encoded enum, so adding a case later cannot strand a session.
ON SAVE the session is written through the SAME path a manual workout takes, so
it inherits overlap dedup, `rescoreManualWorkouts` and delete/merge. `strain`
is left nil deliberately: the engine fills it from the heart rate the strap
actually MEASURED over that window (`ManualWorkoutRescore.scored` →
`StrainScorer`). It is never derived from the typed sets/reps/weight — there is
no validated public path from those to a strain equivalent.
One i18n trap avoided: the catalog's existing "Rest" key is NOOP's SLEEP metric
("Erholung", "Riposo", "Odpoczynek"). The rest timer uses its own "Rest period"
string, or every non-English user would see the word for overnight recovery on
a rest between sets. 27 new strings, all nine locales.
Verification: `xcodebuild` clean for NOOPiOS and Strand (macOS). Full
StrandTests run: 1271 tests, the only 2 failures being TodayCarryOverTests,
which fail identically on a clean checkout of the branch point and are
unrelated to this work. i18n and doc-comment gates pass. Driven end-to-end in
the simulator: started "Upper A", logged 30 kg × 10 (volume read 300 kg),
reached the rest countdown, killed the app, relaunched, resumed — set count,
volume and the still-running countdown all intact.
Not yet done: the strap buzz and the double-tap gesture need validating on real
hardware. BLE behaviour cannot be CI- or simulator-tested.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First real session with a strap, and four things were wrong. TAP-ANYWHERE IS GONE. The screen advanced the session on a tap anywhere on it. In use that fires while scrolling, while typing a weight, or just holding the phone — and a stray advance costs a logged set. The session now moves on exactly two deliberate inputs: the button, or a double-tap on the strap. The inherited brief asked for tap-anywhere by name; using it settled the question. SET ENTRY MOVED INTO THE REST. You cannot type a weight with the bar in your hands. `advance` now takes no set values at all: the set is recorded the instant it ends, carrying its timing, and the numbers are filled in during the rest that follows (`updateLastSet`). The final set — which no rest follows — is filled in during the cool-down. Every keystroke goes to the engine and to disk, so a crash mid-rest keeps what was typed. TARGETS ARE A PLAN, NOT A RECORD. A program line now plans ONE rep count and a WEIGHT. The first draft carried a rep RANGE and a target RPE — a literal reading of one planning spreadsheet — but no weight, which is the number actually written on a program. Target RPE is gone from the UI on principle: RPE is how hard a set FELT, knowable only after doing it; planning one means guessing at your own effort and reading the guess back as data. `liftSession.sessionRpe` is a real column, created by `v42-lift-log` itself rather than added by a follow-up migration, so the schema commit stands alone. The rating was being appended to the session's free-text note, which a human can read and nothing can compute with. Foster's session load is sRPE x duration, so the rating has to be a number or the metric cannot be derived at all. Nullable — a skipped rating must not read as an effortless 0. TWO CLOCKS AND TWO BUZZ PATTERNS. The session total answers "how long have I been here"; a second clock answers "how long has THIS set/rest been running", which is the number you act on between sets. And the strap now says two different things: ONE pulse confirms a double-tap registered (with the phone face-down there is otherwise no way to know), THREE means the rest is nearly up. Two cues that felt identical answered "did it just buzz?" badly. Verification: `swift test` in WhoopStore — 457 tests, 0 failures, including 4 new ones pinning the target-weight and session-RPE columns and that a skipped rating stays nil. Full StrandTests: 1276 tests, the only 2 failures being TodayCarryOverTests, which fail identically on a clean checkout under a non-US region (they compare against a US date format) and are unrelated to this work. Both app targets build — the macOS one caught a `onChange(of:)` that is macOS 14+ and would have shipped broken from an iOS-only build. i18n and doc-comment gates pass; 12 new strings across all nine locales. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers "where can I see what I lifted, and where are the numbers" — the last substantial gap in the feature. `LiftMetrics` (StrandAnalytics, pure, 29 tests) computes six figures, every one of them arithmetic the user can redo by hand from the sets listed on the same screen. That is the design constraint: six honest numbers rather than one invented composite score, because a "workout score out of 100" feels satisfying and tells you nothing about what to change. volume load Σ(weight × reps), working sets only sets per muscle direct×1.0 + indirect×0.5 session load sRPE × minutes (Foster) work vs rest measured from the taps est. 1RM Epley, ≤12 reps, always labelled estimated RPE profile mean · count at/above 8 · unrated THE 0.5 IS NOT A HOUSE CONVENTION. The 2025 Sports Medicine dose-response meta-regression compared crediting a secondary mover's set as 1.0, 0.5 and 0.0, found the evidence strongest for fractional, and used it in its primary models. The reference doses shown against the weekly counts were derived under that same operationalisation, so the credit and the doses have to move together or the comparison silently stops meaning anything. Pinned by a test. Set counts are NOT filtered by RPE, deliberately and against the obvious instinct: the reference doses come from unfiltered working-set counts, so filtering to "hard" sets would compare a smaller number against a scale built from a larger one. Warm-ups are excluded; nothing else is. The muscle vocabulary stays at 20 in 4 regions. Coarser hides an untrained hamstring inside a full "Legs" bucket — the gap invisible precisely because the bucket is full. Finer splits a triceps into three counts of ~1.3 and goes past the resolution the evidence is measured at. The weekly view rolls up to regions; the data stays at 20. One display bug caught by checking the screen's own arithmetic by hand: session load read 21 under a caption saying "RPE 8 × 2 min", because the caption rounded to whole minutes while the figure was computed from 2.67. Anyone verifying it would have got 16 and concluded the app invents numbers. The caption now shows the minutes the calculation actually used. Also refused, and documented in the source so it stays refused: anything feeding `workout.strain` from typed sets/reps/weight; per-exercise muscle weightings (no published table exists, and inventing one makes every downstream figure fiction wearing the costume of precision); and acute:chronic workload ratios or injury-risk warnings. Verification: StrandAnalytics 1627 tests, WhoopStore 457, both 0 failures. Both app targets build. i18n and doc-comment gates pass; 28 new strings across nine locales. Driven end-to-end in the simulator: logged a 4-set session and checked every figure by hand — volume 500 kg = 30×8 + 32.5×8, e1RM 41.2 = 32.5×(1+8/30), chest 4.0 direct with front delts and triceps at 2.0 each from four indirect sets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second real gym session, three findings, all of them structural.
A GYM IS NOT A QUEUE. The session walked the plan strictly in order and showed
one set at a time, so you could neither see what was coming nor move on when a
machine was occupied. The engine is now slot-based: every set of every exercise
is a row on one scrollable sheet, and ANY pending set can be started at any
time. Order is a suggestion the session follows by default, not a rail.
• completed sets keep their check and the numbers, and can be redone
• weight/reps/RPE are editable per row, with GHOST values in priority order:
the previous set of this exercise IN THIS SESSION, then the same set number
last session, then the program's target. A placeholder stays a placeholder —
a number nobody typed must never become data.
• green = the set being worked, amber = the rest after it, so you can find
your place at arm's length
• the clocks and the one action are pinned to the bottom and never scroll away
THE SESSION NOW OUTLIVES ITS SCREEN. It used to live inside the sheet that
displayed it, so swiping that sheet down tore the view down and with it the
strap's double-tap handler and the rest tick. The session looked alive — still
on disk, "Resume" brought it back — but was DEAF: no taps, no buzz, in the app
or out of it. Re-entering also re-fired the five-second warning, because the
"already warned" flag was view state that reset on every present.
`LiftSessionController` now owns the engine, the tick, the buzz gating and the
persistence, at the app root. The sheet is one rendering of it; `LiftSessionBar`
— a bar above the tab bar, reachable from ANY tab — is another. Swiping the
sheet away MINIMISES the session rather than ending it, and a session left
running by a previous launch returns as the bar rather than as a sheet thrown
in the user's face.
THE CONFIRMATION BUZZ WAS QUEUED BEHIND STATE WORK. A strap double-tap ran a
commit (JSON encode + defaults write) before firing the buzz whose entire job
is to say "that registered". The buzz now goes out first, before any state is
touched. And the two patterns stay distinguishable on a knocked-about wrist:
ONE pulse confirms a double-tap, THREE means the rest is nearly up.
Also fixed while proving it in the simulator: the session bar rendered ON TOP
of the tab bar, because the safe-area inset was applied to the TabView itself.
It now clears it with `NoopMetrics.tabBarClearance`, the constant every other
screen uses.
Verification: 1281 tests, the only failures being the two TodayCarryOverTests
that fail identically on a clean checkout under a non-US region. 27 engine
tests rewritten for the slot model, covering out-of-order starts, redo without
double-counting, abandoning an unfinished set, and undo taking back a redo.
Both app targets build. i18n and doc-comment gates pass; 16 new strings across
nine locales. Driven in the simulator: swiped the sheet away mid-rest and
watched the bar keep counting, killed and relaunched the app and watched the
session return as the bar with the clock still correct, then reopened it.
Still to validate on hardware: whether the confirmation buzz now feels
immediate. BLE latency is not something a simulator can answer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…double-taps Second round of gym feedback. Two of the five were already fixed by the workout-sheet rewrite; the rest are here. PHANTOM DOUBLE-TAPS — the one that was costing logged sets. A gesture reaches the app TWICE on a busy link: live through `FrameRouter.handle(frame:)`, and again when the strap offloads its banked event log, because `dispatchLiveGestureIfFresh` runs over every offload frame and accepts any event timestamped within 45 s of now — which a gesture from moments ago obviously is. `AppModel.handleDoubleTap`'s 1.2 s debounce cannot catch it: the replay lands many seconds later, long after the debounce expires. With the Lift Log claiming the gesture, the phantom silently advanced the session. De-duplicated on the event's OWN timestamp, which is what separates the two cases: one gesture replayed carries one timestamp, two genuine taps carry two. A missing timestamp fails OPEN, because a real gesture must never be swallowed by an absent field. Read-side only — no new writes, no change to the connection path or the freshness window. 4 tests, built on the captured DOUBLE_TAP frame already in the protocol fixtures. FORGETTING AN EXERCISE. The vocabulary is typo-accumulating by design — every misspelling became a permanent picker entry. Each suggestion now has a delete, behind a confirmation that says plainly what it does NOT do: logged sets snapshot their own exercise name and muscles, so forgetting one never touches a recorded session. A CAP OF 500 remembered exercises, per device. Set far beyond any real training history, so reaching it means something has gone wrong; the honest response is to say so rather than to silently drop what was typed or evict something still in use. An EXISTING name always updates regardless — otherwise a user at the cap could no longer fix the classification of an exercise they train weekly. KEYBOARD DISMISSAL. The numeric fields have no return key, so the only way out was the "Done" toolbar button while the keyboard covered half the workout sheet. Tapping anywhere else now dismisses it, via `simultaneousGesture` rather than `onTapGesture` — a plain tap gesture on the container would swallow taps meant for the very controls that advance the session. THE CONFIRMATION-BUZZ DELAY is now as short as software can make it: the buzz fires before any state work (already landed last commit), and `BLEManager.send` writes straight to CoreBluetooth with no app-side queue. What remains is the BLE round trip and the strap's own haptic engine. Already fixed by the previous commit, verified rather than re-done: undo is one unbounded stack for the WHOLE session, not per exercise — two new tests pin that it walks back ACROSS exercise boundaries to the warm-up, with no depth limit. Verification: 1287 app tests and 460 store tests, the only failures being the two TodayCarryOverTests that fail identically on a clean checkout under a non-US region. Both targets build; i18n and doc-comment gates pass. NOT FIXABLE IN SOFTWARE, and stated plainly rather than papered over: an impact that the strap itself decides is a double-tap arrives as an ordinary DOUBLE_TAP event with nothing to distinguish it from a deliberate one. The strap does the detecting; NOOP only receives the verdict. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found while writing the handover docs, not by use — which is the point of writing them against the code. The workout-sheet rewrite (5a87885) dropped the "warm-up set" toggle and nothing replaced it. `isWarmup` survived in the engine, the store and the metrics; only the way to SET it was gone. Since warm-ups are excluded from volume and from the per-muscle counts, every warm-up was being counted as a working set — inflating the single figure the whole feature rests on. A user warming up three times before a heavy squat gained three phantom quad sets. THE SET NUMBER IS NOW THE TOGGLE. Tapping it turns the row's "1" into an amber "W" and back — one tap, without leaving the row or opening anything. The mark can be made BEFORE the set is performed, which is when you actually know it is a warm-up. An unperformed set has no record to carry the flag, and inventing one would create a set nobody did, so the mark is held in `LiftSessionController.pendingWarmups` and applied the instant the set is recorded. It lives in the CONTROLLER rather than the view, so it survives the sheet being minimised and applies however the set was closed out — button, strap, or the minimised bar. Verification: 32 engine tests (3 new — a pre-marked warm-up survives onto the set, un-marking restores it to a working set, and a warm-up still keeps its weight and reps for the record). Both targets build. i18n and doc-comment gates pass; 2 new strings across nine locales. Driven in the simulator: marked set 1, saw it render "W" in amber with its completed check while the header read "0 of 4 sets done" — completed, and correctly outside the tally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The empty-state card promised "working sets, rep range, target RPE, rest and your own technique note". Two of those five have not existed since the tap-anywhere fix: a line plans ONE rep count rather than a range, target RPE was deliberately removed from the UI (RPE is how hard a set FELT, knowable only after doing it), and a planned WEIGHT was added — which the card never mentioned. So the first thing a new user reads named two fields that are not there and omitted one that is. It is also the most-seen string in the feature during development, because a clean install shows it every time. Now: "working sets, reps, weight, rest and your own technique note" — exactly the fields in `LiftProgramItemSheet.targetsSection`. The stale key is removed rather than left orphaned, and all nine translations are adapted from the existing ones rather than re-invented, so the wording stays in each translator's voice. The same stale list was in the LiftProgramEditorSheet header comment; corrected there too. Verification: `Tools/i18n_audit.py --ci upstream/main` passes with all ten locales present. Both app targets build. Confirmed on a fresh install in the simulator — the card renders the corrected list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…it showed Two defects from the first full gym session, both of which cost logged data. THE SESSION WALKED BACK TO A MACHINE THE USER HAD LEFT. `advance` moved to `nextPendingSlot` — the first uncompleted slot in PLAN order. Being able to start any pending set is the feature that handles an occupied machine, so a user who skips exercise 1 and starts exercise 3 always has an earlier slot still pending; plan order then threw them back to exercise 1 after every single set. Reported verbatim: "I switched to a different move because the machine was occupied. When I double-tap for the next set, it reverts to the first set of the exercise I couldn't do earlier." `slotAfter(_:)` now prefers the next uncompleted set of the SAME exercise and falls back to plan order only once that exercise is finished — which is also just what lifting is: you do your sets on the machine you are standing at. The skipped exercise is deferred, not forgotten, and is exactly what comes next. `nextPendingSlot` keeps its plan-order meaning for opening a session and for knowing when the sheet is done. A COMPLETED SET RECORDED NOTHING. The sheet showed "50 x 10" in grey for every set; completing one stored weight and reps as NIL, because a placeholder was never committed. 19 sets came back from a real session with no numbers at all and a session volume of zero — the measurements the feature exists to keep, gone. The rule that "a number nobody entered must never become data" is the right instinct pointed the wrong way: silence is not conservative when the alternative is losing the measurement. A set now records `carry(for:lastSession:)` — the same numbers the sheet was already showing, resolved in the same order: this exercise earlier in THIS session, then the same set number last session, then the program's target. It is not an inference about what the user did; it is the plan they pressed "set done" against, and the UI renders it as a real entry rather than a placeholder so it is visible and correctable during the rest. A set that was not actually performed is corrected to 0. RPE is deliberately NOT carried. Weight and reps are a plan, knowable in advance; RPE is how hard a set felt, knowable only afterwards. Carrying it would invent the one figure nobody can guess, and would silently make the RPE card report full coverage for sets nobody rated. `lastSession` is the one layer the engine cannot know, so the controller holds it and the sheet hands it over. It lives on the controller and not in the view because the strap can complete a set while the sheet is minimised, and a set recorded that way has to carry the same numbers the sheet would have shown. Verification: 9 new engine tests covering both defects — the occupied-machine path, the deferred exercise coming back, carry from the target / from this session / from last session, RPE never carrying, nothing-to-carry staying nil rather than inventing a zero, and a typed 0 sticking. StrandTests 1519, the only 2 failures being the pre-existing locale-dependent TodayCarryOverTests. Both app targets build. Confirmed in the simulator: completing a set on a later exercise advances within that exercise and records 30 x 8 without typing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things a real session asked for, all display-only. THE SET HEADING WRAPPED MID-WORD. `strandOverline` renders ALL-CAPS with +1.4 tracking, and the column was 26pt wide, so the heading came out as "SE" over "T" — photographed on a phone at the gym. It is now 34pt, shared as a constant by the heading and every row so the set number sits centred directly under its label, and the heading row is `lineLimit(1)` with a scale floor. That last part matters beyond this one label: four short headings across a phone width in ten languages is exactly where a wrap reappears, and a wrapped heading breaks the column alignment for every row beneath it. LIVE HEART RATE ON THE CONTROL BAR. Asked for directly. It belongs on the strip that never scrolls, beside the clocks, because the use is a glance mid-set while you are holding a bar. Reads `AppModel.bpm` — the smoothed, spike-filtered value every screen is supposed to show, never the raw per-beat number. Shown as "—" when there is no value, the way LiveView reports it, so a stopped stream is distinguishable from a missing feature and the clocks beside it do not shift. Display only: nothing here feeds a score. Effort stays HR-derived from what the strap MEASURED over the session window, computed by the analytics engine. THE MINIMISED BAR NOW SAYS WHAT YOU ARE LIFTING. It read "Set 2 — working", which is the one thing you already know. It now reads "Set 2 — 8 x 30 kg": while resting those are what the set recorded, while working they are what completing it would record. That is the question you have when the phone is face-down on a bench and the sheet is minimised. Falls back to the old wording when neither reps nor weight is known, since "Set 2 — x" helps nobody. One new string, "HR", with the abbreviations the existing "Live HR" entry already uses per language (de HF, es/fr/it/pt-PT FC, zh 心率); Russian follows that entry's own word choice rather than the clinical ЧСС. Verification: `Tools/i18n_audit.py --ci upstream/main` passes with all ten locales and no new hardcoded literals; `doc_comment_lint.py` passes. Both app targets build. Confirmed in the simulator: the heading renders on one line with the numbers centred under it, HR shows on the control bar, and the minimised bar reads "Lying Leg Curl / Set 2 — 8 x 30 kg". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
THE REST IS NOW A BAND BETWEEN TWO SET ROWS, not an amber tint on the row above it. Tinting the finished set said the wrong thing — that set is over; what is running is the gap after it — and from across a gym floor it was not obvious which gap that was. A band sits where a rest actually is, and carries the countdown as well as the colour: the control bar has the same number, but this is where your eyes already are, and once the sheet is scrolled to a later exercise the band is the only thing that says which rest. Green is untouched. HEART RATE ON THE MINIMISED BAR, beside the clock. The sheet's control bar already shows it; the bar is the surface you actually have open with the phone face-down. Shown only when there IS a reading, unlike the sheet, which holds a "—" so its clocks do not shift — a capsule with four things competing for width gains nothing from a permanent dash. THE REST CLOCK WAS LABELLED WITH THE SLEEP METRIC'S STRING. `String(localized: "Rest")` resolves to the catalog's "Rest" key, which is NOOP's SLEEP metric: "Erholung" in German, "Riposo" in Italian. So the gym rest timer was labelled "recovery" in every non-English locale. This is the exact collision CLAUDE.md and the handover notes both warn about, reintroduced by the workout-sheet rewrite (b2e23bd). It now uses "Rest period" — the string that exists for this — and the clock label got `lineLimit(1)` with a scale floor, because the correct translations are longer ("Отдых между подходами") and must shrink rather than wrap the control bar. The bar and the Lock Screen activity that follows both need the same wording and the same numbers, so both now come from one `LiftSessionController.presentation` rather than a copy each. Two copies of this drifted the moment one was edited. Two new strings, "HR" and "Set %lld", in all ten locales; "HR" uses the abbreviations the existing "Live HR" entry already uses per language, and "Set %lld" is the existing "Set %lld — working" with its clause dropped, so each translator's own noun for a set is preserved. Verification: `i18n_audit.py --ci upstream/main` and `doc_comment_lint.py` pass; StrandTests 1519, only the two pre-existing locale-dependent failures; both app targets build. Confirmed in the simulator. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Live Activity carrying what the minimised bar carries — state, exercise, reps x weight, heart rate and the clock — so a set can be followed with the phone locked on a bench. Asked for after a gym session; it is the surface that matters when the phone is not in your hand. SEPARATE ACTIVITY TYPE from the existing live-HR one. They answer different questions and have different lifetimes: the HR activity lives as long as the strap streams, this one as long as a session is open. Rather than stack two banners, the app suppresses the HR activity while a session is running — this one already carries the heart rate. PUSHES ARE CONTENT-DRIVEN, NOT CLOCK-DRIVEN. Both timers are `Text(timerInterval:)`, so the Lock Screen counts on its own between pushes and the app only sends state when something a person would notice changes: the stage, the exercise, the numbers, or (at most every 10 s) the heart rate. A banner that pushed once a second to animate a clock would be throttled by ActivityKit and look worse for it. `Text(date, style: .timer)` looked equivalent and was not: it rendered "25 minutes" on the Lock Screen where a gym timer has to read 25:02. Only running it in the simulator caught that. The wording and the numbers come from the same `LiftSessionController.presentation` the in-app bar renders, so the two surfaces cannot describe the session differently. Everything user-facing is formatted APP-side and passed in as strings — the widget extension ships no string catalog, so a literal there would be untranslatable copy in ten locales. Reuses the existing Live Activity opt-out rather than adding a second switch: a user who turned Live Activities off meant all of them. Activities are re-adopted after a relaunch and ended on the whole `activities` list, the same way the HR controller does, so an opt-out or a finished session cannot leave a stale banner. Verification: confirmed on the Lock Screen in the simulator — the banner renders the exercise, "Set 2 — 8 x 30 kg", the progress line and a clock that advances on its own. `i18n_audit.py --ci upstream/main` reports no new un-extracted literals; `doc_comment_lint.py` passes; both app targets build; StrandTests 1519 with only the two pre-existing locale-dependent failures. NOT yet confirmed on hardware: the seconds digits render as "--" in simulator screenshots, which is how the simulator captures a system-drawn live timer, but that is worth a glance on a real Lock Screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both surfaces hid the HR readout whenever there was no value, to save width. The first thing that produced was a report that there is no heart rate in the minimised tab or on the Lock Screen — the strap simply was not streaming at the time, and an absent readout is indistinguishable from an absent feature. That is the worse failure. Mid-workout the difference is something to act on: a dash says the strap has stopped reading, where nothing at all says the app never had the feature. The sheet's control bar already reasoned this way and kept its "—"; the bar and the Lock Screen now match it, greyed when empty and rose when live. The Lock Screen also puts the heart rate and the clock side by side rather than stacked, which is both the minimised bar's layout — the thing it is a remote view of — and better aligned: `Text(timerInterval:)` reserves width for the widest value it could display, so a trailing-aligned timer never lined up with the text beneath it. Verification: photographed both states in the simulator by temporarily feeding `LiveState` a fixed bpm — bar and Lock Screen both render "132" in rose, and both render a grey dash once the injection is removed. That injection is NOT in this commit; it existed only long enough to take the screenshots, and the file it touched is byte-identical to its committed state. StrandTests 1519 with only the two pre-existing locale-dependent failures; both app targets build; `i18n_audit.py --ci upstream/main` and `doc_comment_lint.py` pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Filling in a program on a phone — a dozen exercises, each with a muscle group, sets, reps, a weight, a rest and a technique note — is the most tedious thing in the feature, and it is exactly the work a keyboard and a spreadsheet do well. This reads a filled-in sheet and creates the programs. THE TEMPLATE (`docs/lift-log-program-template.xlsx`) is fillable but not editable, which is what makes the import reliable: the sheet is protected so the header row cannot be renamed, reordered or deleted (the import matches on those names), only the data cells accept typing, and the two muscle columns are dropdowns over the closed 20-token vocabulary — a muscle cannot be misspelled into something the importer then has to reject. A second sheet carries instructions and a worked example and is ignored on import. It is generated by `Tools/make_lift_program_template.py`, which writes the OOXML by hand: the repository gains no Python dependency, and the bytes are reproducible. `LiftProgramSheetImporterTests` parses the SHIPPED template, so dropping a column from it fails the suite rather than the user's import. TWO FORMATS, ONE PARSER. `.xlsx` is what the template is; CSV is accepted too, because every spreadsheet on every platform can write it. Detection is by ZIP magic bytes, the same idiom `DataBackup` uses to tell a zipped backup from a bare SQLite file. `XlsxSheet` is a deliberately small reader — the first worksheet, as text — built on ZIPFoundation and Foundation's XMLParser, both already in the package. Nothing new enters the dependency graph. The parser lives in `Packages/StrandImport` and writes nothing: it returns programs and warnings, and the caller decides. That keeps it testable with no store, no app and no simulator, which is where all 15 of its tests run. REAL SPREADSHEETS, NOT IDEAL ONES. Delimiters are sniffed, so the `;` Excel writes in most of Europe works. `40,5` and `40.5` both mean 40.5, and `40,5 kg` is tolerated. Blank rows are skipped wherever they are. A blank target stays NIL rather than becoming a planned zero. An unrecognised muscle WARNS and leaves that line unclassified; it never guesses and never drops the row. The vocabulary is a stored-data contract, so deciding that "Shoulders" means front delts would put sets in a bucket the user did not choose. Warnings name the sheet's own row number so they can be acted on, and are non-fatal by design — one typo should not cost eleven good lines and twelve round trips. Nothing is written until the user has seen what it will create. Imported exercises are also remembered with their classification, so the picker offers them next time and the per-muscle rollup resolves the same name the same way. Cross-platform: the five lift tables are pinned `ios_only` in both `schema_oracle.json` copies, so this importer has no Room twin to keep in step — the same stated position as the rest of the feature, not a new divergence. Verification: 15 new tests in StrandImport (264 total, 0 failures) covering both formats against the same content, grouping, European decimals, blank-stays-nil, muscle resolution, the warning text and row numbers, the shipped template's columns, and every refusal. Both app targets build; `i18n_audit.py --ci` passes with 13 new strings in all ten locales; `doc_comment_lint.py` passes. End-to-end in the simulator: a filled .xlsx and a .csv both previewed and imported, and the rows landed with the right order, targets and classifications. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A gym session on 15 Sep 2026 had 2-3 double-taps that did not register, and nothing on the strap log could say why. A tap can be dropped in three places, and none of them left a line: - the de-duplication holds back an event it has already dispatched; - `dispatchLiveGestureIfFresh` refuses a gesture that reaches the app only through a sync, more than 45 s after it happened; - `AppModel.handleDoubleTap` ignores a tap within 1.2 s of the previous one. Each now appends one line, carrying the strap's own event timestamp so it can be matched against the "Double-tap -> ..." line a dispatched tap leaves. A late tap is only logged within ten minutes of its timestamp, so offloading a day of ordinary history stays silent. A reported miss with none of these lines was never sent by the strap. Two tests pin the lines, and each fails without its line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
From a gym session on 15 Sep 2026, three changes to how a session ends: Grey numbers stay grey. Finishing a set records its timing only; a set's numbers are its own only once typed, and one chain (`carry(for:lastSession:)`) supplies the grey numbers the sheet, the minimised bar and the Lock Screen all show. Writing the grey numbers in at "set done" made the user type over guesses. Finishing asks instead of assuming. If some sets have nothing typed in (never started, or finished without typing), one question completes them all with their grey numbers or leaves them out; a set with anything typed always saves, its blank fields taking grey values, so nothing saves empty. If a set count was changed with the plus/minus row, a second question asks whether the program keeps it; the row no longer rewrites the program on every tap. Save waits for both answers. A finished session can be edited: weights, reps, RPE, warm-up marks and session RPE. Fields are text parsed on Save, and only a field that changed is written, so an untouched pound value is not nudged by a round trip. Two gaps found while checking this in the simulator are fixed too: the Lift Log list did not show a session saved from the sheet until the screen was reopened (the sheet lives above the screen and a save does not always bump the refresh counter), and a disabled primary button looked pressable because the shared style does not dim; the Lift Log's save buttons now dim with the design system's disabled opacity. Tests cover the grey chain, what finishing saves either way, the program comparison, the edit parsing and the saved-session signal; the key ones were each seen to fail with their fix removed. All 13 new strings are in the ten locales. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ence Records the session on be377cf (three asked-about items held; 2-3 taps did not register), Utku's three requested changes added to ryanbr#2099 as 7ad5822 and 45caa74 (grey numbers stay grey, one complete/discard question and a program question at finish, editing finished sessions), the tap-drop log lines, the hub reload and dimmed save buttons found in the simulator, CI 18/18, and the build af27d0a. Invariants 5, 18 and 27-31 updated; reply draft now covers both sessions and waits for the next one.
By @bhelm. Addresses the deadlock in #2211: the ledger failed on main, and the sanctioned repair could not run because it validated against the same stale base it was meant to repair, while the other door was refused by design. `--repair-stale-base` proceeds only when all six of these hold: semantic authority equal to the exact base, finding identities equal, counters equal, typed dispositions equal, current authority exactly derived, current baseline exactly derived. So it regenerates metadata for a tree whose governed state has not moved and cannot carry anything of the branch's own, which is the migration the error message demanded without offering. Two properties make that guard real rather than decorative, and both were checked. The flag appears nowhere in .github/workflows, so the routine gate cannot take the repair path and skip the conditions. And the acceptance suite tests the REFUSALS, not only the success: branch-added debt, disposition changes, a non-exact map, a non-exact baseline, and the flag requiring the guarded refresh. The CI narrowing loses no coverage. Tools/tests holds exactly three modules and the workflow names all three; what it buys is that a future unrelated test file cannot silently join this job or move its floor. tools-python.yml still discovers everything against its own floor of 200. The floor here rises 103 to 113 in the same change, which is right: a named list nobody counts is how a list quietly loses an entry. The baseline goes 294 to 309. Those are #2098's lift-log entries, whose API has tests and no production callers because #2099 is the consumer and has not landed. Recording them is reversible: when #2099 lands they become production callsites, the findings go, and the ledger reports debt decreased with cleanup optional, exactly as it already does for StrandAnalytics. Leaving main red until an unrelated PR merges is not reversible in the same way. Verified in a PRISTINE clone, because the scanner walks the filesystem and a working tree carrying Packages/*/.build from earlier Swift builds reports a failure that belongs to the directory rather than the change: 113 tests OK, and the ledger reporting no NEW findings. Main currently fails two acceptance tests and this branch fails none, so it repairs test_checked_metadata_is_compact_v3_and_expands_losslessly rather than merely not breaking it.
|
Reviewed the delta since The new work holds up
The 13 new strings, all ten locales. Verified by key-set difference against Grey stays grey is the right model, and the comment on One thing I would like your view on
var unenteredSlots: [LiftSlot] {
allSlots.filter { slot in
guard let set = recordedSet(for: slot) else { return true } // never started
return set.weightKg == nil && set.reps == nil && set.rpe == nil // performed, nothing typed
}
}A never-started slot has nothing to lose. A performed slot has That matters most in the workflow this PR leads with. Run a session phone-face-down, advancing on the strap, type nothing: every slot is unentered. "Discard them" then saves a The dialog is honest about it, "sets you did not start, or finished without typing" and "Discarding leaves them out of the session", so this is not hidden. And I can see you rejected keeping them numberless on purpose, since that is what let 19 sets save empty. So I am not asking you to undo that decision. What I am asking is whether the empty session is an outcome you want reachable in one tap. Two ways out, and I do not have a strong preference:
Your feature, your call. I raise it because the face-down session is the case you are selling, and it is the one that lands squarely on it. Smaller notes, none blocking
Nothing here blocks. Answer the empty-session question and I will take it. |
Discarding the unentered sets can empty a session completely. A session run face-down and advanced entirely on the strap has nothing typed, so every slot is unentered and "Discard them" leaves no set behind. That path still wrote a LiftSessionRow with no sets AND a manual workout, and the engine fills a workout's strain from the heart rate the strap measured, so an hour that recorded nothing read back as a workout with a strain on it. Nothing to file, so file nothing: the session row, the set rows and the workout are all skipped when no set survives. The program's set counts are a separate choice the user made explicitly on the second question, so those still apply, and the session is torn down and dismissed exactly as it is after a normal save. That teardown now lives in one place so the two paths cannot drift. Test pins the precondition the guard reads: a five-set session advanced entirely on the strap with nothing typed saves nothing when discarding, and still saves all five when completing.
|
I pushed the guard onto your branch rather than asking you to round-trip for it: Taking the first of the two options I offered, since it leaves your "discard means discard" decision intact:
The part that pushed me from "worth asking" to "worth guarding" was the workout. An empty session was not just an empty row: it also wrote a The test pins the precondition the guard reads rather than the view: five sets advanced entirely on the strap with nothing typed save nothing when discarding, and all five when completing. One thing I could not do from here: I have no macOS, so the app target did not compile locally. CI covers it, and I am waiting on that before merging. If it comes back unhappy with the guard, the fix is mine, not yours. Say if you would rather have done it the other way, keeping performed sets with their timing and letting discard mean only "do not invent numbers". That is still a reasonable shape and easy to swap to later. |
…nd gym session's changes on lift-log-discard-and-edit, build b300401
Lift Log (#2099) shipped its analytics on Swift only. This adds the Kotlin twin, pinned by oracle tests whose expected values are the Swift build's own stdout. `LiftMetrics.kt` mirrors `StrandAnalytics.LiftMetrics`. `LiftMuscle.kt` carries the 20 muscle tokens in Swift's order and spelling, which matters because the encoded list crosses a .noopbak boundary and a reordering would silently remap stored data on restore. Re-review found a real parity bug. Swift's `LiftSetRow.init` normalises a row's secondary list at construction, so `LiftMetrics` never sees a repeated muscle on that side. The Kotlin `Row` carried the list through raw and `muscleCounts` strips only the primary, so a set naming a muscle twice was counted twice for it: Swift credited triceps 0.5 across one indirect set where Kotlin credited 1.0 across two. `Row` now applies the same decode-of-encode at construction, and is no longer a data class because the generated copy() would bypass it. The first oracle could not have caught that: its Swift stub declared LiftSetRow as a plain memberwise struct, modelling LiftMetrics in isolation rather than how it is fed. The stub now carries the real init verbatim and the fixture pins the normalised per-row list, not just the totals it feeds. Re-review also found `LiftMuscle.encodeList` and `decodeList` had no Kotlin test while both claimed a twin, which made the ledger treat them as guarded when nothing guarded them. They are the stored-data contract, so a divergence is silent corruption on restore. Now oracled, including where Swift's split(separator:) omits empty subsequences and Kotlin's split(",") keeps them. Smaller: both oracle tests formatted doubles with the default locale and would fail on a comma-decimal machine while passing in CI, now pinned to Locale.ROOT; and perExercise's assumption that ord is unique is recorded in the source, because Kotlin's sortedBy is stable where Swift's sorted(by:) is not. Measured on a clean worktree and on the branch at the same base: total drift goes from 62 to 55. Twelve Swift-side requirements retire, five arrive. The five are fromRaw, Kotlin-only by design, and four test-only-callsite findings, and they fold into the #2229 authority decision. Android has Room lift entities and an importer but no Lift Log screen, so nothing in the app calls this engine yet. It is deliberately ported ahead of its consumer.
…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>
…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>
…losed, not pending Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
220 upstream commits. Android changes are dropped with the tree the fork does not carry (including ryanbr#2277/ryanbr#2262, which are Android-only). WHOOP 5 R-R, the HRV/Charge loss upstream users hit after 11.5/11.6 -> 11.7: upstream's strict single-transport read (ryanbr#2117/ryanbr#1505) returns an EMPTY window for beats banked before transport labelling, which blanked HRV and Charge for those nights. The fork does not adopt it. RRTransportReconciler now selects per beat within +/-3 s by WHOOP 5 precedence (labelled v18 history > unlabelled history > labelled standard > labelled live > unlabelled live > unlabelled standard > no provenance), restores pre-ryanbr#2195 standard units on read, and keeps legacy beats wherever nothing better covers them. The "cannot be scored" Today note, legacyWhoop5RRWithheld and Whoop5RR.legacyUnscorableNight are removed; the rrTransport export line states recorded/labelled dates only. Upstream schema v45/v46 are appended as v68 (rr source index) and v69 (lift log). Other resolutions: Lift Log (ryanbr#2099) merged dormant with no entry point; the AI Coach master switch reads the fork's CoachFeaturePrefs and now withdraws the brief, its notifications and widget text, and refuses sends at egress; the fork's cancellation-aware runUnescalated kept with upstream's priority tests; ryanbr#2208 "whose charge is this" applied to the shared StrapBatteryDisplayState; ryanbr#2082 HR gap segments threaded through LiquidLiveHR; ryanbr#2210 orphaned HealthKit workout cleanup also guards the native-workout read. Upstream tests for diagnostics and coach errors the fork implements differently (ryanbr#2094, ryanbr#2058, ryanbr#2245) are not carried. Analysis migration required: yes (recipe 6 -> 7, bounded 21-day rescore: R-R source precedence and WHOOP 5 standard units change stored HRV/Charge). Verified: swift test WhoopProtocol 798, WhoopStore 744, StrandAnalytics 2584, StrandImport 353, OuraProtocol 243, StrandTraining 55, NoopLocalAccess 14 (StrandDesign: 5 pre-existing German-locale failures); xcodebuild Strand build + StrandTests 3363 passed; NOOPiOS build; doc_comment_lint; i18n_audit --ci. Not validated on a strap.
#2328) docs(changelog): say which platforms the 11.8.0 lift log actually shipped on An Android user went looking for the gym log book the release notes announced and could not find it. They were right that there is no entry point: #2099 added the Lift Log to Strand only. Android got #2098's schema and Room twin and #2232's LiftMetrics twin, which is the storage and the maths, and no UI at all. The entry said: An on-device gym log book: build a session, then move between sets with a double-tap on the strap instead of reaching for the phone. Stored in a new schema with a matching Room twin, and the set metrics are computed by the same engine on both platforms. Every clause is true, which is what made it misleading rather than wrong. Both platform phrases in it, "a matching Room twin" and "on both platforms", attach to the two halves Android DID get, so on Android the whole thing reads as an announcement of a feature you have. The same text ships in the in-app changelog on both platforms and in the release doc. Corrected in all four places it appears: the two AppChangelog copies, and the item plus the prose section in docs/releases/v11.8.0.md. It now names iPhone and Mac for the log book, says plainly that Android has the groundwork and not the book, and points at #2327, which tracks the Android UI. Platform-scoping an entry is already the house convention: "iPhone only", "Android only" and "(iPhone and Mac)" all appear in existing entries. The release HEADLINE is deliberately unchanged. Its localized key is derived from its own English text (sha1("A gym log book on your wrist, ...")[:8] = e0f00272), so rewording it would re-key the string and orphan the translation in all eight Android locales plus the xcstrings side, for a headline that is not itself false: the release did ship a gym log book, on two of the three platforms. Changelog item strings are not localized (only the release title is), so this needs no translation work. No behavioural change.
… 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>
…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>
…'s open question
…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>
… 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>
|
Thanks for the guard, and for pushing it here rather than sending me round-trip for it. You asked whether I would rather have kept performed sets with their timing. After four more gym sessions: yes, and #2403 does that. A set that was done counts as done, with the numbers typed into it or the grey ones it was planned with, and the finish screen only asks about sets that were never started. Discard stays "do not invent numbers" rather than "forget it happened": a discarded set is kept as 0 × 0, left out of every figure, and can be filled in later under Edit sets if the discard was a mistake. Your guard is untouched and still has the last word. If no set was done and the rest are discarded, no session, no set rows and no workout are written, and the finish screen says so before Save. |
…nbr#2099 answered; ryanbr#2402 still open
What this PR does
A gym log book in NOOP, built on the tables in #2098. You build a program once, then run it at the gym, and every set is logged with the weight, reps and the rest you actually took.
Double-tap the strap to finish a set. One buzz confirms the tap and three buzzes mean the rest is nearly over, so a whole session can be run with the phone face-down.
Why it belongs in NOOP rather than a separate lifting app:
workout, so its strain comes from the heart rate the strap measured (strain: nilon save);How it works
.xlsxtemplate (.csvalso works).Figures: volume, Foster session load, Epley 1RM (up to 12 reps), RPE coverage, and estimated sets per muscle against a ~4 sets/week research reference. Each can be recomputed by hand from the sets shown; estimated figures are labelled as such, and nothing is presented as a target or as "complete". No composite score, and Effort is never modified.
MuscleGroups(#1968): the classification here is user-assigned and stored, so by #1968's own rule ("published attribution wins where a source provides it") it applies to sets logged in NOOP, and #1968's matcher stays the fallback for Hevy/Liftosaur imports. A 20→13 mapping can follow as its own PR.BLE change (read-side only):
FrameRouterde-duplicatesDOUBLE_TAPby the event's own timestamp, remembering recent timestamps within the live window, so a gesture replayed from the strap's offloaded event log is not dispatched twice — a phantom advance would silently skip a set. No new writes; the connection path and live window are unchanged. Each point where a double-tap can be dropped (a suppressed replay, a recent tap that only arrived through a sync, the 1.2 s debounce) now logs one line, so a tap reported as not registering can be checked against the strap log.Not included: Android screens, and a Kotlin twin of
LiftMetrics(pure functions; it can follow using theCLAUDE.mdoracle method once the figures are settled).Type of change
How it was tested
swift test: WhoopStore 597, StrandAnalytics 2005, StrandImport 317, WhoopProtocol 706 — 0 failures.xcodebuild test(macOS): 1875 tests; only the two locale-dependentTodayCarryOverTestsfail, identically onmainon this machine.Tools/parity_ledger.py) on this branch merged into currentmain: one new identity,XlsxSheet.rawPart— internal test support for asserting the template's column lock, kept on purpose.Checklist
swift testinPackages/<name>)android/(via Android CI — no local Android SDK)onChange(of:perform:)as deprecated in four Lift Log screens; that single-parameter form is what the macOS 13 target needs, and shared screens upstream use it the same wayStrandDesigntokens — colours and fonts are tokens; a few small spacing values have no matching token (similar to existing screens)docs/CONTRIBUTING.mdStrand.xcodeproj/) or any secrets/keystoresRelated issues
Builds on #2098 (merged as
8818477d).