Add the v46 schema for an in-app strength log, with its Room twin - #2098
Conversation
Adds the storage layer for a gym log book: saved programs and the sessions
run from them. Schema only — no UI, and nothing here feeds a score.
Five deviceId-keyed tables (migration `v46-lift-log`):
liftExercise the user's own exercise vocabulary (NOOP ships no catalogue)
liftProgram a reusable program, e.g. "Upper A"
liftProgramItem one exercise line inside a program: the targets, including
the planned weight — a program line plans a WEIGHT, not just
a rep range
liftSession one gym session, keyed to its `workout` row by that
table's natural key (deviceId, startTs, sport), UNIQUE.
Carries `sessionRpe` as a NUMBER, not appended to the note:
Foster's session load is sRPE x duration, so the rating has
to be computable or the metric cannot be derived at all
liftSet one set — rows, not a JSON blob, so "what did I lift for
this exercise last time" is answerable by an index
`LiftMuscle` is a closed 20-token muscle vocabulary across four regions.
Exercise names stay free text; muscle groups do not, because a per-muscle
rollup only means something if the same muscle always lands in the same
bucket. Raw values are a stored-data contract and are pinned by a test.
Set counting uses the fractional method — direct x 1.0, indirect x 0.5 —
matching the 2025 Sports Medicine dose-response meta-regression, which
compared total/fractional/direct crediting and found the evidence strongest
for fractional. `liftSetCounts` returns `direct` and `indirect` alongside
`fractional` so the arithmetic is inspectable. Counts are deliberately NOT
filtered by RPE: the reference doses were derived from unfiltered working-set
counts, so filtering would compare against a scale built from a larger number.
Warm-ups are excluded; nothing else is.
`liftSet` snapshots an exercise's classification at log time, so reclassifying
an exercise later never silently rewrites what past weeks were counted as.
Effort is untouched. NOOP's strain is HR-derived (Karvonen %HRR -> Edwards
TRIMP) and there is no validated public path from typed sets/reps/weight to a
strain equivalent, so this migration adds no load or strain column anywhere —
consistent with the existing imported-lifting path, which already stores
`strain: nil`.
All five tables are in `DeviceRegistryStore.deviceScopedTables`, including the
child tables: they join by id rather than by a foreign key, so leaving them off
would let a "delete all my data" strip the parents and leave every logged set
behind.
Pinned in both `schema_oracle.json` copies as `ios_only` with a stated reason:
the Room twin is a tracked follow-up. Nothing here feeds a score, so a device
without these tables computes identical metrics.
Every create — tables AND indexes, consistently — is `ifNotExists`, following
the v38 idiom: GRDB keys applied migrations by identifier, so a fork that
already carries these tables under a different one converges rather than
failing the migrator.
Verification: `swift test` in Packages/WhoopStore — 558 tests, 0 failures,
including 33 new ones covering the migration, CRUD, device scoping, the
fractional counts and the RPE profile. `Tools/doc_comment_lint.py` and
`Tools/i18n_audit.py --ci main` both pass. The Android JVM suite could not be
run here (no JDK/SDK on this machine); the shared oracle copies are verified
byte-identical and no Room entity is declared for these tables, which is what
`SchemaOracleTest`'s ios_only branch requires.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`docs/CONTRIBUTING.md` is explicit that a migration cannot land until its twin lands with it, and that the oracle's divergence ledger should only ever shrink. The five tables were pinned `ios_only` with a stated reason, which is the documented escape hatch — but using it here would push that ledger the wrong way by five entries, so this closes it instead. Five Room entities mirroring the GRDB tables field for field, a Room migration 39 -> 40 twinning `v46-lift-log`, and both oracle copies moved from `ios_only` to `both`. `SchemaOracleTest` compares Room's KSP-exported schema against the same fixture the GRDB suite checks, so column order, affinity, nullability, defaults, keys and indices are all verified rather than asserted. Two shapes worth naming. `archived` and `isWarmup` carry @ColumnInfo(defaultValue = "0"): a Kotlin constructor default never reaches the schema, and leaving the annotation off would reproduce the `room-omits-sql-default` divergence on brand-new tables — the one chance there is to simply not have it. Each `id` gains the existing `sqlite-text-pk-nullable` ledger entry, which every other TEXT-keyed table on both platforms already carries. WHAT IS NOT HERE, stated rather than left to be discovered: no DAO and no Compose screens, so nothing on Android reads or writes these tables yet. That is deliberate. A gym log book is worth exactly what it feels like to tap through between sets with the phone face-down, and screens written without a device to try them on would compile and be bad to use. This is the half that can be proved correct without a device; an Android user should take the other half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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. I checked this against the code rather than reading the description, and the storage layer holds up well. One gap worth closing before it lands. VerifiedAll seven version pins agree: The argument for rows over a JSON blob is right, and so is keeping load and strain out of the schema entirely. The gap: the Android delete list did not move with the Swift one
The precedent sits a few lines above where the new entries belong: // v38-apple-step-hour: no Android importer writes this table, but a `.noopbak` restored FROM iOS
// carries its rows, so "delete this device's data" must clear them here too ...That is this situation exactly. CI cannot see it either. The #771 adopt-serial re-key block needs the same five, since its own comment says it covers "the SAME table set as deleteDeviceData". Five |
… re-key Review catch on ryanbr#2098: `DeviceRegistryStore.deviceScopedTables` gained all five tables on the Swift side and `DeviceRegistryDao` — its Android twin — did not, so the two lists disagreed. The ryanbr#771 adopt-serial block covers the same table set and was short the same five. Five DELETE and five UPDATE OR IGNORE queries, wired into `deleteDeviceData` and `reKeyDeviceRows`, plus the three hand-written test fakes that implement the DAO interface. `deleteDeviceDataCallsEveryDaoDeleteMethod` reflects over the DAO's `delete*For` methods and asserts each is wired, so the new ones are covered the moment they exist — which is also why a table with no method at all was invisible to it before. One correction to the rationale, since it is load-bearing for the next person who reads it. The cited precedent says a `.noopbak` restored FROM iOS carries the rows, but Android cannot restore an iOS backup today: `DataBackup.importFrom` is the only restore entry point, it classifies any file carrying `grdb_migrations` as BackupOrigin.MAC, and rejects it outright, pointing at the WHOOP CSV export instead. That rejection landed 2026-06-27, two months before the v38 comment that reasons from it, and it has no test. So the v38 note is stale. The change is still right, for reasons that survive the correction: the two lists are twins and should not diverge, the tables exist on Android as of this PR so anything that later writes them inherits a correct delete path, and the re-key is live the moment a row exists — an unre-keyed row is orphaned, not merely undeleted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — you're right, One note on the reasoning: Android can't currently restore an iOS |
|
@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. |
`liftSession(deviceId:startTs:sport:)` had no caller outside its own test. The app reads sessions by range (`liftSessions`) and deletes them by id; nothing looks one up by the workout's natural key. Found by upstream's parity ledger, which reports it as a `test-only-callsite` identity — debt this PR would otherwise hand the maintainer to baseline. Same rule that removed `deleteLiftSet` and `liftRpeProfile`: the store reads what the app reads, and nothing speculative. Uniqueness of that natural key is still pinned by `testSessionUpsertIsIdempotentByNaturalKey` and enforced by the unique index, so the only thing lost is a read nobody makes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e tool ryanbr#2098 squash-merged as 8818477; its branch retired to archive/lift-log-schema. ryanbr#2099 rebased onto main at the maintainer's request (4553eb3, 27 commits), and waits only on the gym session he asked for. Records the squash-merge rebase recipe and saves dist/tools/xcmerge.py, the key-based Localizable.xcstrings merge proven on this rebase, since the scratchpad it was written in does not survive the session.
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.
…trap (#2099) A gym log book built on the #2098 tables. Build a program once, run it at the gym, and every set is logged with the weight, reps and the rest actually taken. Double-tap the strap to finish a set: one buzz confirms, three mean the rest is nearly over, so a session runs with the phone face-down. A session saves as a normal workout with `strain: nil`, so its strain comes from the heart rate the strap MEASURED rather than from typed sets and reps. Programs are a name plus ordered exercise lines, importable from the committed .xlsx template. Any set can be started at any time, so a busy machine never blocks a session. Grey numbers stay grey: a number becomes a set's own only when typed, and finishing asks once whether the untyped sets are completed with their grey numbers or left out, rather than assuming either. A finished session can be edited, and only changed fields are written back. Figures are volume, Foster session load, Epley 1RM, RPE coverage and estimated sets per muscle, each recomputable by hand from the sets shown, with no composite score and Effort never modified. FrameRouter de-duplicates DOUBLE_TAP by the event's own timestamp so a gesture replayed from the strap's offloaded event log is not dispatched twice, which would have silently skipped a set. Read-side only; the connection path and live window are unchanged. Each point where a tap can be dropped logs one line, carrying strap-clock timestamps only. Tested over five gym sessions on a WHOOP 5.0. The first four each found something, three of them silent wrong data: sets saving no numbers, 45.5 kg stored as 455, and a figure computed two ways. Not included: Android screens, and a Kotlin twin of LiftMetrics, both to follow. Zero-set guard added on merge: discarding the unentered sets can empty a session completely, and that path had written a session row with no sets plus a manual workout the engine would fill strain into, so an hour that recorded nothing read back as a workout. Nothing to file now files nothing. Thanks to @UtkuDenizAltiok. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#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.
…v11.8.0 Found by the compile, which is the only check these files have. - CoachView: a duplicated `#endif` from a conflict resolution left the iOS voice block unterminated. The brace checker cannot see this; only the compiler can. - FrameRouter: the fork's `isFirstDelivery` and upstream's `dispatchDoubleTapOnce` are two implementations of the same timestamp-keyed dedup, and upstream's lift log (ryanbr#2098) dispatches through its own. Unified on `dispatchDoubleTapOnce` — it prunes its map and logs a suppressed replay — and removed the fork's now-dead helper rather than leaving it to compile quietly forever. The fork's late-arrival diagnostic is kept. Two fork commits were dropped as genuinely redundant, not merged: - 9138f1e ("move all configuration into Settings"): upstream's ryanbr#2243 built CoachSettingsView, which is a strict superset of the fork's ConfigureCoachSection. Keeping both would have shipped two coach settings screens. The fork's four unique surfaces — the Today-synthesis instruction, the notification-title instruction, derivedTrendsBar and the morning brief — are grafted onto upstream's screen instead. - df60f18 (isDeviceLocked): superseded by the fork's own sleep-window rule and built on the measured rule upstream deleted. RootTabView keeps the fork's order (Today/Day/Sleep/Trends/More) with Coach reached from More, and now also honours upstream's ryanbr#2269 master switch on that route: a brief notification can call openCoach() with Coach switched off. Verified: Strand (macOS) + NOOPiOS build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
What this PR does
Storage for an in-app strength log: saved programs and the sessions run from them. Five
deviceId-keyed tables under migrationv46-lift-log, with the Room twin in the same PR. Schema only — no UI, nothing feeds a score. The app is #2099, which builds on this.liftExerciseliftProgramliftProgramItemliftSessionworkoutrow by(deviceId, startTs, sport)liftSetKey decisions
workoutwithstrain: nil, like the existing imported-lifting path.sessionRpeis a number, so Foster load (sRPE × duration) can be computed.LiftMuscleis a closed 20-token vocabulary; raw values are a stored-data contract pinned by a test.liftSetsnapshots the muscle classification at log time, so reclassifying later never rewrites past weeks.IF NOT EXISTS.Android: five Room entities field-for-field with GRDB, Room migration 39 → 40, both
schema_oracle.jsoncopies moved fromios_onlytoboth, andDeviceRegistryDaodelete + #771 re-key for all five tables. No DAO or Compose UI yet, so nothing on Android reads these tables today.Type of change
How it was tested
swift testinPackages/WhoopStore: 575 tests, 0 failures (migration, CRUD, device scoping, fractional counts).SchemaOracleTestchecks Room's exported schema against the shared fixture (columns, order, types, defaults, keys, indices);DeviceRegistryTestcovers the delete fan-out.Checklist
swift testinPackages/<name>)android/(via Android CI — no local Android SDK)StrandDesigntokens (no UI in this PR)docs/CONTRIBUTING.mdStrand.xcodeproj/) or any secrets/keystoresRelated issues
App on top of this: #2099.