Skip to content

Add the v46 schema for an in-app strength log, with its Room twin - #2098

Merged
ryanbr merged 4 commits into
ryanbr:mainfrom
UtkuDenizAltiok:lift-log-schema
Sep 14, 2026
Merged

ryanbr merged 4 commits into
ryanbr:mainfrom
UtkuDenizAltiok:lift-log-schema

Conversation

@UtkuDenizAltiok

@UtkuDenizAltiok UtkuDenizAltiok commented Sep 11, 2026 •

Copy link
Copy Markdown

What this PR does

Storage for an in-app strength log: saved programs and the sessions run from them. Five deviceId-keyed tables under migration v46-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.

table holds
liftExercise the user's own exercise names and the muscles they assigned (no shipped catalogue)
liftProgram a reusable program, e.g. "Upper A"
liftProgramItem one program line: sets, reps, weight, rest, note
liftSession one session, paired to its workout row by (deviceId, startTs, sport)
liftSet one set per row, so "last time for this exercise" is an index read

Key decisions

  • No load or strain column. Strain stays HR-derived; a lift session is saved as a workout with strain: nil, like the existing imported-lifting path.
  • sessionRpe is a number, so Foster load (sRPE × duration) can be computed.
  • LiftMuscle is a closed 20-token vocabulary; raw values are a stored-data contract pinned by a test.
  • Set counting is fractional (direct 1.0, indirect 0.5, per the 2025 Sports Medicine dose-response meta-regression), warm-ups excluded, not filtered by RPE.
  • liftSet snapshots the muscle classification at log time, so reclassifying later never rewrites past weeks.
  • All five tables are device-scoped on both platforms, child tables included (they join by id, not foreign key).
  • Every table and index is created IF NOT EXISTS.

Android: five Room entities field-for-field with GRDB, Room migration 39 → 40, both schema_oracle.json copies moved from ios_only to both, and DeviceRegistryDao delete + #771 re-key for all five tables. No DAO or Compose UI yet, so nothing on Android reads these tables today.

Type of change

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

How it was tested

  • swift test in Packages/WhoopStore: 575 tests, 0 failures (migration, CRUD, device scoping, fractional counts).
  • Android CI green: SchemaOracleTest checks Room's exported schema against the shared fixture (columns, order, types, defaults, keys, indices); DeviceRegistryTest covers the delete fan-out.
  • Both oracle copies are byte-identical; doc-comment lint and i18n audit pass.
  • Parity ledger: no new identities from this PR. Twin-map drift is left to the scheduled re-derive (ci: let the parity gate see main, so drift stops ambushing other PRs #2142).
  • No BLE code is touched.

Checklist

  • Swift package tests pass for any package I touched (swift test in Packages/<name>)
  • Android unit tests pass if I touched android/ (via Android CI — no local Android SDK)
  • No new build warnings introduced
  • UI changes use only StrandDesign tokens (no UI in this PR)
  • No hardcoded hex frame bytes; protocol facts live in the schema / decoders
  • Follows the conventions in docs/CONTRIBUTING.md
  • I did not commit generated output (Strand.xcodeproj/) or any secrets/keystores

Related issues

App on top of this: #2099.

ryanbr and others added 2 commits September 11, 2026 14:33
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>
UtkuDenizAltiok added a commit to UtkuDenizAltiok/noop that referenced this pull request Sep 11, 2026
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.
@ryanbr

ryanbr commented Sep 11, 2026

Copy link
Copy Markdown
Owner

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.

Verified

All seven version pins agree: v46-lift-log registered after v45, Room @Database(version = 40), SCHEMA_VERSION = 40, and roomVersion: 40 in both oracle copies, which are byte-identical. Every table and every index carries .ifNotExists, the natural keys are unique indexes, and LiftMuscle is a closed 20 token vocabulary with the never-rename contract stated in its header. Column order, affinity, nullability and defaults are machine checked by the oracle on both sides, so the Room twin is genuinely verified rather than asserted.

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

DeviceRegistryStore.deviceScopedTables gains all five tables, with a good note on why the child rows have to be there. DeviceRegistryDao.kt holds the Android twin of that list, and this PR does not touch it.

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. .noopbak is a whole SQLite file copy (dbFile.inputStream().copyTo(zip), and restore copies the file straight back over), so an iOS user's logged sets do reach an Android install. There, "delete all of this device's data" clears nothing in the five lift tables and leaves every logged set behind. Having no Android DAO is why the tables stay empty in normal use, and it is also why nothing else catches this.

CI cannot see it either. DeviceRegistryTest.deleteDeviceDataCallsEveryDaoDeleteMethod reflects over the DAO's declared delete*For methods and asserts each one is wired into deleteDeviceData. A table with no method at all is invisible to that check, which is why the branch is green.

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 DELETE FROM ... WHERE deviceId = :deviceId queries, their wiring, and five re-key twins settles it.

… 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>
@UtkuDenizAltiok

UtkuDenizAltiok commented Sep 11, 2026 •

Copy link
Copy Markdown
Author

Thanks — you're right, DeviceRegistryDao hadn't moved with deviceScopedTables. Fixed in 203b2b82: five delete…For queries wired into deleteDeviceData, the five UPDATE OR IGNORE re-key twins in the #771 adopt-serial block, and the three test fakes that implement the DAO. Android CI is green.

One note on the reasoning: Android can't currently restore an iOS .noopbak — DataBackup.importFrom classifies it as BackupOrigin.MAC and rejects it — so the premise in the v38 comment looks stale. The change still stands for parity with the Swift list, and so anything that later writes these tables on Android already has a correct delete and re-key path.

@UtkuDenizAltiok

Copy link
Copy Markdown
Author

@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>
@ryanbr
ryanbr merged commit 8818477 into ryanbr:main Sep 14, 2026
16 checks passed
@UtkuDenizAltiok
UtkuDenizAltiok deleted the lift-log-schema branch September 14, 2026 10:03
UtkuDenizAltiok added a commit to UtkuDenizAltiok/noop that referenced this pull request Sep 14, 2026
…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.
ryanbr pushed a commit that referenced this pull request Sep 15, 2026
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.
ryanbr pushed a commit that referenced this pull request Sep 15, 2026
…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>
ryanbr added a commit that referenced this pull request Sep 19, 2026
#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.
ksricharank pushed a commit to ksricharank/noop that referenced this pull request Sep 19, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants