From f611e7034fee9f11a51e40271234c4ab0f5e48cb Mon Sep 17 00:00:00 2001 From: ryanbr Date: Wed, 9 Sep 2026 13:26:47 +0000 Subject: [PATCH 1/4] store: add the v46 schema for the in-app strength log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Sources/WhoopStore/Database.swift | 155 ++++ .../WhoopStore/DeviceRegistryStore.swift | 7 + .../Sources/WhoopStore/LiftLogStore.swift | 712 ++++++++++++++++++ .../Sources/WhoopStore/LiftMuscle.swift | 126 ++++ .../WhoopStoreTests/LiftLogStoreTests.swift | 526 +++++++++++++ .../Resources/schema_oracle.json | 420 ++++++++++- .../app/src/test/resources/schema_oracle.json | 420 ++++++++++- 7 files changed, 2364 insertions(+), 2 deletions(-) create mode 100644 Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift create mode 100644 Packages/WhoopStore/Sources/WhoopStore/LiftMuscle.swift create mode 100644 Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift diff --git a/Packages/WhoopStore/Sources/WhoopStore/Database.swift b/Packages/WhoopStore/Sources/WhoopStore/Database.swift index c363e8147e..b03a34e0c2 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/Database.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/Database.swift @@ -936,6 +936,161 @@ extension WhoopStore { migrator.registerMigration("v45-rr-source-index") { db in try db.create(index: "rrInterval_source_suspect", on: "rrInterval", columns: ["srcChannel", "tsSuspect"]) } + // v46-lift-log: the in-app strength log — saved programs and the sessions run from them. + // + // NOOP can already IMPORT a lifting history (Hevy CSV / Liftosaur JSON via LiftingImporter), but + // that path collapses each workout to a session summary — volume load, set count, top set — + // because there has never been anywhere to put an individual set. These five tables are that + // place. A logged session still lands in `workout` like any other (so Workouts / Today / Effort + // are unchanged); these rows hang beside it and carry the detail the workout row cannot. + // + // Deliberately NO load/strain column anywhere here: `workout.strain` stays the HR-measured + // number the analytics engine computes, and lifting volume is derived on read from the sets + // themselves. Nothing in this migration feeds a score. + // + // Shape notes: + // • Five flat, deviceId-keyed tables joined manually by id — this schema has no foreign keys + // anywhere and does not start here. Every table carries `deviceId` so `deleteAllData` + // (DeviceRegistryStore.deviceScopedTables) clears the whole feature; a child table keyed + // only by its parent's id would silently survive a delete. + // • `id` is a client-generated TEXT identifier so a row can be edited/deleted by id and a + // backup round-trips — the labMarker (v17) idiom. + // • `liftSession` is keyed to its workout row by the same natural key the workout table uses, + // (deviceId, startTs, sport), enforced UNIQUE. One session per workout row, no orphan pairs. + // • Sets are ROWS, not a JSON blob on the session. "What did I lift for this exercise last + // time" is the read the whole feature exists for, and it must be answerable by an index + // rather than by decoding every session ever recorded. + // • Timestamps are unix seconds (Int) like every other table; booleans are `.integer` 0/1, + // never `.boolean` (GRDB declares that BOOLEAN → NUMERIC affinity, which diverges from + // Room's INTEGER — see `grdb-boolean-affinity` in schema_oracle.json). + // • Every create here is `ifNotExists` — tables AND indexes, consistently — so the + // migration is a no-op against a database that already carries the schema (the v38 + // idiom). GRDB keys applied migrations by identifier, so a fork carrying these tables + // under a different one converges instead of failing the migrator. + // + // Pinned in schema_oracle.json as `ios_only` with a stated reason: the Room twin is a follow-up, + // not part of this change. + migrator.registerMigration("v46-lift-log") { db in + // The user's own exercise vocabulary. NOOP ships NO exercise catalogue: an exercise is + // whatever the user typed, and it is remembered here the first time they use it so it can + // be offered back later with the muscle group they gave it. That consistency is what makes + // a per-muscle-group rollup honest — the same name always resolves to the same group, + // rather than to whatever was typed on the day. + try db.create(table: "liftExercise", options: [.ifNotExists]) { t in + t.column("id", .text).primaryKey() + t.column("deviceId", .text).notNull() + t.column("name", .text).notNull() + // Canonical LiftMuscle tokens, never free text — a rollup only means something if + // the same muscle always lands in the same bucket. Nullable so an exercise can be + // recorded before it has been classified. + t.column("primaryMuscle", .text) + // Comma-joined LiftMuscle tokens, or NULL. Short, never queried alone, trivially + // mirrorable in Room — a join table would be three tables of ceremony for a list of + // two or three. + t.column("secondaryMuscles", .text) + t.column("createdAt", .integer).notNull() // unix seconds + t.column("lastUsedTs", .integer) // unix seconds; recency for the picker + } + // One entry per name per device, so recording a name twice updates rather than duplicates. + try db.create(index: "idx_liftExercise_natural", on: "liftExercise", + columns: ["deviceId", "name"], options: [.unique, .ifNotExists]) + + // A saved program: "Upper A", "Lower A". Held separately from the sessions run from it so + // editing a program never rewrites history — a session snapshots the name it ran under. + try db.create(table: "liftProgram", options: [.ifNotExists]) { t in + t.column("id", .text).primaryKey() + t.column("deviceId", .text).notNull() + t.column("name", .text).notNull() + t.column("note", .text) + t.column("createdAt", .integer).notNull() // unix seconds + t.column("updatedAt", .integer).notNull() // unix seconds; drives most-recent-first + t.column("archived", .integer).notNull().defaults(to: 0) // 0/1, hidden not deleted + } + // Programs list most-recently-touched first. + try db.create(index: "idx_liftProgram_device_updatedAt", + on: "liftProgram", columns: ["deviceId", "updatedAt"], options: [.ifNotExists]) + + // One exercise line inside a program: the TARGETS (what you intend to do). The session + // records what actually happened. `ord` is the position in the program; deliberately NOT + // unique with programId, because reordering two lines would collide mid-swap on a unique + // index and the id primary key already guarantees row identity. + try db.create(table: "liftProgramItem", options: [.ifNotExists]) { t in + t.column("id", .text).primaryKey() + t.column("deviceId", .text).notNull() + t.column("programId", .text).notNull() + t.column("ord", .integer).notNull() + t.column("exercise", .text).notNull() + // No muscle column here on purpose: `liftExercise` owns an exercise's classification, + // and duplicating it on the program line is a second place for it to drift. + t.column("targetSets", .integer) + t.column("targetRepsLow", .integer) // rep range low end, e.g. 8 of "8-10" + t.column("targetRepsHigh", .integer) // rep range high end + t.column("targetRpe", .double) // 1-10, the user's own scale + // A program line plans a WEIGHT, not just a rep range — it is the number actually + // written on a program. Kilograms, like every stored weight; display converts. + t.column("targetWeightKg", .double) + t.column("restSec", .integer) // intended rest after each set + t.column("note", .text) // the user's technique cue, verbatim + } + try db.create(index: "idx_liftProgramItem_device", on: "liftProgramItem", + columns: ["deviceId"], options: [.ifNotExists]) + try db.create(index: "idx_liftProgramItem_program_ord", on: "liftProgramItem", + columns: ["programId", "ord"], options: [.ifNotExists]) + + // One gym session. (deviceId, startTs, sport) is the workout table's natural key, so this + // row and its `workout` row identify each other without a foreign key. `programId` is + // nullable: a session can be logged freehand with no program behind it. + try db.create(table: "liftSession", options: [.ifNotExists]) { t in + t.column("id", .text).primaryKey() + t.column("deviceId", .text).notNull() + t.column("startTs", .integer).notNull() // unix seconds; matches workout.startTs + t.column("endTs", .integer) // nil while the session is still running + t.column("sport", .text).notNull() // matches workout.sport + t.column("programId", .text) // nil for a freehand session + t.column("programName", .text) // snapshot: renaming a program never rewrites history + // 0-10 Borg CR10, as rated by the user. Foster's session load is sRPE x duration, so + // the rating has to be a number in its own column or the metric cannot be derived at + // all. Nullable: a session whose rating was skipped simply has no session load, and a + // 0 would read as "effortless" rather than "unrated". + t.column("sessionRpe", .double) + t.column("note", .text) + } + // One lift session per workout row, and the index that serves date-ordered history reads. + try db.create(index: "idx_liftSession_natural", on: "liftSession", + columns: ["deviceId", "startTs", "sport"], options: [.unique, .ifNotExists]) + + // One set. `ord` is the position within the whole session (so the tap-through order is + // reconstructible); `setIndex` is 1-based within its exercise (so "set 3 of 4" survives). + // `exercise` is denormalised rather than pointing at a program item, because a session must + // stay readable after its program is edited or deleted. + try db.create(table: "liftSet", options: [.ifNotExists]) { t in + t.column("id", .text).primaryKey() + t.column("deviceId", .text).notNull() + t.column("sessionId", .text).notNull() + t.column("ord", .integer).notNull() // order within the session + t.column("exercise", .text).notNull() + // The classification AS IT WAS when the set was logged, snapshotted like `exercise` + // itself. Reclassifying an exercise later is an explicit bulk action, not a silent + // rewrite of what past weeks were counted as. + t.column("primaryMuscle", .text) + t.column("secondaryMuscles", .text) + t.column("setIndex", .integer).notNull() // 1-based within the exercise + t.column("weightKg", .double) // kilograms; display units convert + t.column("reps", .integer) + t.column("rpe", .double) // 1-10 as rated by the user + t.column("isWarmup", .integer).notNull().defaults(to: 0) // 0/1; warmups excluded from volume + t.column("startTs", .integer) // when the set began (unix seconds) + t.column("endTs", .integer) // when it ended + t.column("restSec", .integer) // rest ACTUALLY taken after this set + t.column("note", .text) + } + // "What did I lift for this exercise last time" — the read the feature exists for. + try db.create(index: "idx_liftSet_device_exercise", on: "liftSet", + columns: ["deviceId", "exercise"], options: [.ifNotExists]) + // Replaying one session in order. + try db.create(index: "idx_liftSet_session_ord", on: "liftSet", + columns: ["sessionId", "ord"], options: [.ifNotExists]) + } return migrator } } diff --git a/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift b/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift index 1c38d097a3..edbdccce30 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/DeviceRegistryStore.swift @@ -146,6 +146,13 @@ public struct DeviceRegistryStore: Sendable { // so forgetting that source must clear them — otherwise an imported phone's hour-by-hour step // history survives the delete (the same privacy defect this list exists to close). "appleStepHour", + // v46-lift-log: every table in the strength log is deviceId-keyed, including the child rows + // (liftProgramItem, liftSet). They are joined to their parents by id, not by a foreign key, so + // if the children were left off this list a "delete all of this device's data" would strip the + // programs and sessions and leave every exercise line and every logged set behind — the exact + // privacy defect this list exists to close, and one the deviceId-column guard test could not + // catch for a child table keyed only by its parent. + "liftExercise", "liftProgram", "liftProgramItem", "liftSession", "liftSet", ] /// Permanently delete every recorded sample/derived row belonging to one device, across all diff --git a/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift new file mode 100644 index 0000000000..6a1911d1df --- /dev/null +++ b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift @@ -0,0 +1,712 @@ +import Foundation +import GRDB + +// MARK: - v45 store: the in-app strength log (programs, sessions, sets) +// +// Mirrors the established LabMarkerStore / AppleStepHourStore idiom precisely: plain Codable row +// structs, raw `Row` fetch + manual decode, idempotent upserts keyed by the natural key, all GRDB +// work through the actor's `syncWrite` / `syncRead` helpers. +// +// The four tables are flat and deviceId-keyed, joined manually by id — this schema carries no +// foreign keys. A logged session also exists as an ordinary `workout` row; the two find each other +// through the workout table's natural key (deviceId, startTs, sport), which `liftSession` pins with +// a UNIQUE index. +// +// Nothing here computes or stores a load score. `workout.strain` remains the HR-measured number the +// analytics engine produces; volume is derived on read from the sets, where the arithmetic is +// visible (`LiftSetRow.volumeKg`). + +// MARK: - Rows + +/// One exercise in the user's own vocabulary. NOOP ships no catalogue: an exercise is whatever the +/// user typed, remembered here the first time it is used so it can be offered back with the muscle +/// group they gave it. Keeping the name→group mapping in one place is what makes a per-muscle-group +/// rollup mean the same thing from one session to the next. +public struct LiftExerciseRow: Equatable, Codable, Sendable { + public var id: String + public var deviceId: String + /// Exactly as the user typed it. The natural key is (deviceId, name), so case and spacing are + /// preserved rather than normalised — their vocabulary, shown back verbatim. + public var name: String + /// Canonical classification. Nil until the user has classified this exercise. + public var primaryMuscle: LiftMuscle? + /// Also-worked muscles, in the order the user listed them. Never contains `primaryMuscle`. + public var secondaryMuscles: [LiftMuscle] + /// Unix seconds. + public var createdAt: Int + /// Unix seconds; most-recently-used floats to the top of the picker. Nil until first used. + public var lastUsedTs: Int? + + public init( + id: String, + deviceId: String, + name: String, + primaryMuscle: LiftMuscle?, + secondaryMuscles: [LiftMuscle] = [], + createdAt: Int, + lastUsedTs: Int? + ) { + self.id = id + self.deviceId = deviceId + self.name = name + self.primaryMuscle = primaryMuscle + self.secondaryMuscles = LiftMuscle.decodeList( + LiftMuscle.encodeList(secondaryMuscles, excluding: primaryMuscle)) + self.createdAt = createdAt + self.lastUsedTs = lastUsedTs + } + + static func decode(_ row: Row) -> LiftExerciseRow { + LiftExerciseRow( + id: row["id"], + deviceId: row["deviceId"], + name: row["name"], + primaryMuscle: LiftMuscle(rawValue: row["primaryMuscle"] ?? ""), + secondaryMuscles: LiftMuscle.decodeList(row["secondaryMuscles"]), + createdAt: row["createdAt"], + lastUsedTs: row["lastUsedTs"] + ) + } +} + +/// A saved program — the reusable plan ("Upper A"), not a session run from it. +public struct LiftProgramRow: Equatable, Codable, Sendable { + public var id: String + public var deviceId: String + public var name: String + public var note: String? + /// Unix seconds. + public var createdAt: Int + /// Unix seconds. Drives most-recently-touched-first ordering in the programs list. + public var updatedAt: Int + /// Hidden from the picker but kept, so old sessions still resolve their program. + public var archived: Bool + + public init( + id: String, + deviceId: String, + name: String, + note: String?, + createdAt: Int, + updatedAt: Int, + archived: Bool + ) { + self.id = id + self.deviceId = deviceId + self.name = name + self.note = note + self.createdAt = createdAt + self.updatedAt = updatedAt + self.archived = archived + } + + static func decode(_ row: Row) -> LiftProgramRow { + LiftProgramRow( + id: row["id"], + deviceId: row["deviceId"], + name: row["name"], + note: row["note"], + createdAt: row["createdAt"], + updatedAt: row["updatedAt"], + archived: row["archived"] + ) + } +} + +/// One exercise line in a program: the TARGETS. What actually happened lives in `LiftSetRow`. +public struct LiftProgramItemRow: Equatable, Codable, Sendable { + public var id: String + public var deviceId: String + public var programId: String + /// Position within the program, 0-based. + public var ord: Int + /// The exercise NAME. Its muscle classification lives in `liftExercise`, resolved by name — + /// one owner, so a program line and the vocabulary can never disagree. + public var exercise: String + public var targetSets: Int? + /// Rep range low end — the 8 of "8-10". Nil when the line has no rep target. + public var targetRepsLow: Int? + public var targetRepsHigh: Int? + /// Target RPE on the user's own 1-10 scale. + public var targetRpe: Double? + /// Intended rest after each set, seconds. + public var restSec: Int? + /// The user's own technique cue, stored and shown back verbatim. + public var note: String? + + public init( + id: String, + deviceId: String, + programId: String, + ord: Int, + exercise: String, + targetSets: Int?, + targetRepsLow: Int?, + targetRepsHigh: Int?, + targetRpe: Double?, + restSec: Int?, + note: String? + ) { + self.id = id + self.deviceId = deviceId + self.programId = programId + self.ord = ord + self.exercise = exercise + self.targetSets = targetSets + self.targetRepsLow = targetRepsLow + self.targetRepsHigh = targetRepsHigh + self.targetRpe = targetRpe + self.restSec = restSec + self.note = note + } + + static func decode(_ row: Row) -> LiftProgramItemRow { + LiftProgramItemRow( + id: row["id"], + deviceId: row["deviceId"], + programId: row["programId"], + ord: row["ord"], + exercise: row["exercise"], + targetSets: row["targetSets"], + targetRepsLow: row["targetRepsLow"], + targetRepsHigh: row["targetRepsHigh"], + targetRpe: row["targetRpe"], + restSec: row["restSec"], + note: row["note"] + ) + } +} + +/// One gym session. Pairs 1:1 with a `workout` row through (deviceId, startTs, sport). +public struct LiftSessionRow: Equatable, Codable, Sendable { + public var id: String + public var deviceId: String + /// Unix seconds; the same instant as the paired `workout.startTs`. + public var startTs: Int + /// Nil while the session is still running. + public var endTs: Int? + /// The same string as the paired `workout.sport`. + public var sport: String + /// Nil for a freehand session with no program behind it. + public var programId: String? + /// The program's name AS IT WAS when the session ran, so a later rename never rewrites history. + public var programName: String? + public var note: String? + + public init( + id: String, + deviceId: String, + startTs: Int, + endTs: Int?, + sport: String, + programId: String?, + programName: String?, + note: String? + ) { + self.id = id + self.deviceId = deviceId + self.startTs = startTs + self.endTs = endTs + self.sport = sport + self.programId = programId + self.programName = programName + self.note = note + } + + static func decode(_ row: Row) -> LiftSessionRow { + LiftSessionRow( + id: row["id"], + deviceId: row["deviceId"], + startTs: row["startTs"], + endTs: row["endTs"], + sport: row["sport"], + programId: row["programId"], + programName: row["programName"], + note: row["note"] + ) + } +} + +/// One set. Stored as its own row rather than folded into the session, because per-exercise history +/// ("what did I lift for this last time") has to be answerable from an index. +public struct LiftSetRow: Equatable, Codable, Sendable { + public var id: String + public var deviceId: String + public var sessionId: String + /// Order within the whole session, 0-based — reconstructs the order the sets were performed in. + public var ord: Int + /// Denormalised deliberately: a session stays readable after its program is edited or deleted. + public var exercise: String + /// The classification this set was counted under, snapshotted at log time. + public var primaryMuscle: LiftMuscle? + public var secondaryMuscles: [LiftMuscle] + /// 1-based within this exercise, so "set 3 of 4" survives. + public var setIndex: Int + /// Kilograms. Display units convert at the edge; storage is always kg. + public var weightKg: Double? + public var reps: Int? + /// RPE on the user's own 1-10 scale. + public var rpe: Double? + /// Warmup sets are recorded but excluded from volume. + public var isWarmup: Bool + public var startTs: Int? + public var endTs: Int? + /// Rest ACTUALLY taken after this set, seconds — not the target from the program. + public var restSec: Int? + public var note: String? + + public init( + id: String, + deviceId: String, + sessionId: String, + ord: Int, + exercise: String, + primaryMuscle: LiftMuscle?, + secondaryMuscles: [LiftMuscle] = [], + setIndex: Int, + weightKg: Double?, + reps: Int?, + rpe: Double?, + isWarmup: Bool, + startTs: Int?, + endTs: Int?, + restSec: Int?, + note: String? + ) { + self.id = id + self.deviceId = deviceId + self.sessionId = sessionId + self.ord = ord + self.exercise = exercise + self.primaryMuscle = primaryMuscle + self.secondaryMuscles = LiftMuscle.decodeList( + LiftMuscle.encodeList(secondaryMuscles, excluding: primaryMuscle)) + self.setIndex = setIndex + self.weightKg = weightKg + self.reps = reps + self.rpe = rpe + self.isWarmup = isWarmup + self.startTs = startTs + self.endTs = endTs + self.restSec = restSec + self.note = note + } + + /// Volume for this set: weight x reps, in kilograms. Nil unless BOTH are present, and zero for a + /// warmup — a transparent arithmetic figure, never a physiological claim. + public var volumeKg: Double? { + guard !isWarmup, let weightKg, let reps else { return nil } + return weightKg * Double(reps) + } + + static func decode(_ row: Row) -> LiftSetRow { + LiftSetRow( + id: row["id"], + deviceId: row["deviceId"], + sessionId: row["sessionId"], + ord: row["ord"], + exercise: row["exercise"], + primaryMuscle: LiftMuscle(rawValue: row["primaryMuscle"] ?? ""), + secondaryMuscles: LiftMuscle.decodeList(row["secondaryMuscles"]), + setIndex: row["setIndex"], + weightKg: row["weightKg"], + reps: row["reps"], + rpe: row["rpe"], + isWarmup: row["isWarmup"], + startTs: row["startTs"], + endTs: row["endTs"], + restSec: row["restSec"], + note: row["note"] + ) + } +} + +// MARK: - Store + +extension WhoopStore { + + // MARK: Exercises (the user's own vocabulary) + + /// Remember exercises. Keyed on the natural key (deviceId, name), so recording the same name + /// twice updates its classification and recency instead of duplicating it. A muscle field is + /// only overwritten when the caller supplies one, so merely using an exercise never erases the + /// classification the user set for it. + @discardableResult + public func upsertLiftExercises(_ rows: [LiftExerciseRow]) async throws -> Int { + guard !rows.isEmpty else { return 0 } + return try syncWrite { db in + var n = 0 + for r in rows { + try db.execute(sql: """ + INSERT INTO liftExercise + (id, deviceId, name, primaryMuscle, secondaryMuscles, createdAt, lastUsedTs) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(deviceId, name) DO UPDATE SET + primaryMuscle = COALESCE(excluded.primaryMuscle, primaryMuscle), + secondaryMuscles = COALESCE(excluded.secondaryMuscles, secondaryMuscles), + lastUsedTs = MAX(COALESCE(excluded.lastUsedTs, 0), COALESCE(lastUsedTs, 0)) + """, arguments: [ + r.id, r.deviceId, r.name, + r.primaryMuscle?.rawValue, + LiftMuscle.encodeList(r.secondaryMuscles, excluding: r.primaryMuscle), + r.createdAt, r.lastUsedTs, + ]) + n += db.changesCount + } + return n + } + } + + /// The user's exercises, most recently used first and never-used ones after, alphabetical within + /// each. This is the picker's list — built entirely from what they have typed. + public func liftExercises(deviceId: String) async throws -> [LiftExerciseRow] { + try syncRead { db in + try Row.fetchAll(db, sql: """ + SELECT * FROM liftExercise + WHERE deviceId = ? + ORDER BY COALESCE(lastUsedTs, 0) DESC, name ASC + """, arguments: [deviceId]).map(LiftExerciseRow.decode) + } + } + + /// Forget one exercise from the vocabulary. Sets already logged under that name are untouched — + /// they carry their own copy of the name and muscle group, so history never loses meaning. + @discardableResult + public func deleteLiftExercise(id: String) async throws -> Bool { + try syncWrite { db in + try db.execute(sql: "DELETE FROM liftExercise WHERE id = ?", arguments: [id]) + return db.changesCount > 0 + } + } + + // MARK: Programs + + /// Upsert programs by `id`. Returns rows written. + @discardableResult + public func upsertLiftPrograms(_ rows: [LiftProgramRow]) async throws -> Int { + guard !rows.isEmpty else { return 0 } + return try syncWrite { db in + var n = 0 + for r in rows { + try db.execute(sql: """ + INSERT INTO liftProgram + (id, deviceId, name, note, createdAt, updatedAt, archived) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + note = excluded.note, + updatedAt = excluded.updatedAt, + archived = excluded.archived + """, arguments: [ + r.id, r.deviceId, r.name, r.note, r.createdAt, r.updatedAt, r.archived, + ]) + n += db.changesCount + } + return n + } + } + + /// Programs for a device, most recently touched first. `includeArchived` defaults false so the + /// picker shows only live programs. + public func liftPrograms(deviceId: String, includeArchived: Bool = false) async throws -> [LiftProgramRow] { + try syncRead { db in + let sql = includeArchived + ? """ + SELECT * FROM liftProgram + WHERE deviceId = ? + ORDER BY updatedAt DESC + """ + : """ + SELECT * FROM liftProgram + WHERE deviceId = ? AND archived = 0 + ORDER BY updatedAt DESC + """ + return try Row.fetchAll(db, sql: sql, arguments: [deviceId]).map(LiftProgramRow.decode) + } + } + + /// Delete a program and its item lines. Sessions already run from it are NOT touched — they + /// carry their own `programName` snapshot, so history survives the program's deletion. + /// Returns true if a program row was removed. + @discardableResult + public func deleteLiftProgram(id: String) async throws -> Bool { + try syncWrite { db in + try db.execute(sql: "DELETE FROM liftProgramItem WHERE programId = ?", arguments: [id]) + try db.execute(sql: "DELETE FROM liftProgram WHERE id = ?", arguments: [id]) + return db.changesCount > 0 + } + } + + // MARK: Program items + + /// Replace a program's item lines wholesale. The editor hands back the whole list, and deleting + /// then reinserting inside one transaction avoids reconciling removals and reorders row by row. + @discardableResult + public func replaceLiftProgramItems(programId: String, items: [LiftProgramItemRow]) async throws -> Int { + try syncWrite { db in + try db.execute(sql: "DELETE FROM liftProgramItem WHERE programId = ?", arguments: [programId]) + var n = 0 + for r in items { + try db.execute(sql: """ + INSERT INTO liftProgramItem + (id, deviceId, programId, ord, exercise, targetSets, + targetRepsLow, targetRepsHigh, targetRpe, restSec, note) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, arguments: [ + r.id, r.deviceId, r.programId, r.ord, r.exercise, r.targetSets, + r.targetRepsLow, r.targetRepsHigh, r.targetRpe, r.restSec, r.note, + ]) + n += db.changesCount + } + return n + } + } + + /// A program's exercise lines in program order. + public func liftProgramItems(programId: String) async throws -> [LiftProgramItemRow] { + try syncRead { db in + try Row.fetchAll(db, sql: """ + SELECT * FROM liftProgramItem + WHERE programId = ? + ORDER BY ord ASC + """, arguments: [programId]).map(LiftProgramItemRow.decode) + } + } + + // MARK: Sessions + + /// Upsert sessions. Keyed on the NATURAL key (deviceId, startTs, sport) rather than the `id` PK, + /// so re-saving the same session updates it in place even if the caller minted a fresh id — the + /// labMarker rule, and what keeps this row paired 1:1 with its `workout` row. + @discardableResult + public func upsertLiftSessions(_ rows: [LiftSessionRow]) async throws -> Int { + guard !rows.isEmpty else { return 0 } + return try syncWrite { db in + var n = 0 + for r in rows { + try db.execute(sql: """ + INSERT INTO liftSession + (id, deviceId, startTs, endTs, sport, programId, programName, note) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(deviceId, startTs, sport) DO UPDATE SET + endTs = excluded.endTs, + programId = excluded.programId, + programName = excluded.programName, + note = excluded.note + """, arguments: [ + r.id, r.deviceId, r.startTs, r.endTs, r.sport, + r.programId, r.programName, r.note, + ]) + n += db.changesCount + } + return n + } + } + + /// Sessions for a device that started within `[fromTs, toTs]` inclusive, most recent first. + public func liftSessions(deviceId: String, fromTs: Int, toTs: Int) async throws -> [LiftSessionRow] { + try syncRead { db in + try Row.fetchAll(db, sql: """ + SELECT * FROM liftSession + WHERE deviceId = ? AND startTs >= ? AND startTs <= ? + ORDER BY startTs DESC + """, arguments: [deviceId, fromTs, toTs]).map(LiftSessionRow.decode) + } + } + + /// The session paired with a workout row, by that row's natural key. Nil when the workout was + /// not logged through the lift log. + public func liftSession(deviceId: String, startTs: Int, sport: String) async throws -> LiftSessionRow? { + try syncRead { db in + try Row.fetchOne(db, sql: """ + SELECT * FROM liftSession + WHERE deviceId = ? AND startTs = ? AND sport = ? + """, arguments: [deviceId, startTs, sport]).map(LiftSessionRow.decode) + } + } + + /// Delete a session and every set in it. Returns true if a session row was removed. + @discardableResult + public func deleteLiftSession(id: String) async throws -> Bool { + try syncWrite { db in + try db.execute(sql: "DELETE FROM liftSet WHERE sessionId = ?", arguments: [id]) + try db.execute(sql: "DELETE FROM liftSession WHERE id = ?", arguments: [id]) + return db.changesCount > 0 + } + } + + // MARK: Sets + + /// Upsert sets by `id`. Called as each set is logged, so a session in progress is durable set by + /// set rather than only on finish. + @discardableResult + public func upsertLiftSets(_ rows: [LiftSetRow]) async throws -> Int { + guard !rows.isEmpty else { return 0 } + return try syncWrite { db in + var n = 0 + for r in rows { + try db.execute(sql: """ + INSERT INTO liftSet + (id, deviceId, sessionId, ord, exercise, primaryMuscle, secondaryMuscles, + setIndex, weightKg, reps, rpe, isWarmup, startTs, endTs, restSec, note) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + ord = excluded.ord, + exercise = excluded.exercise, + primaryMuscle = excluded.primaryMuscle, + secondaryMuscles = excluded.secondaryMuscles, + setIndex = excluded.setIndex, + weightKg = excluded.weightKg, + reps = excluded.reps, + rpe = excluded.rpe, + isWarmup = excluded.isWarmup, + startTs = excluded.startTs, + endTs = excluded.endTs, + restSec = excluded.restSec, + note = excluded.note + """, arguments: [ + r.id, r.deviceId, r.sessionId, r.ord, r.exercise, + r.primaryMuscle?.rawValue, + LiftMuscle.encodeList(r.secondaryMuscles, excluding: r.primaryMuscle), + r.setIndex, r.weightKg, r.reps, r.rpe, r.isWarmup, + r.startTs, r.endTs, r.restSec, r.note, + ]) + n += db.changesCount + } + return n + } + } + + /// Every set in a session, in the order they were performed. + public func liftSets(sessionId: String) async throws -> [LiftSetRow] { + try syncRead { db in + try Row.fetchAll(db, sql: """ + SELECT * FROM liftSet + WHERE sessionId = ? + ORDER BY ord ASC + """, arguments: [sessionId]).map(LiftSetRow.decode) + } + } + + /// The sets from the most recent session that contained `exercise`, in performed order. + /// + /// This is the read the whole feature exists for: it pre-fills the next session with what you + /// actually did last time, which the user then confirms or overrides. Empty when the exercise + /// has never been logged. `before` excludes the session currently in progress (pass its + /// `startTs`) so a running session never pre-fills from itself. + public func lastLiftSets(deviceId: String, exercise: String, before: Int? = nil) async throws -> [LiftSetRow] { + try syncRead { db in + // Two steps rather than a correlated subquery: find the latest qualifying session, then + // read its sets in order. Served by idx_liftSet_device_exercise + idx_liftSession_natural. + let cutoff = before ?? Int.max + guard let sessionId = try String.fetchOne(db, sql: """ + SELECT s.sessionId FROM liftSet s + JOIN liftSession sess ON sess.id = s.sessionId + WHERE s.deviceId = ? AND s.exercise = ? AND sess.startTs < ? + ORDER BY sess.startTs DESC + LIMIT 1 + """, arguments: [deviceId, exercise, cutoff]) else { return [] } + return try Row.fetchAll(db, sql: """ + SELECT * FROM liftSet + WHERE sessionId = ? AND exercise = ? + ORDER BY ord ASC + """, arguments: [sessionId, exercise]).map(LiftSetRow.decode) + } + } + + /// Per-muscle working-set counts over `[fromTs, toTs]`, from the classification each set was + /// logged under. + /// + /// `fractional` is the headline figure: direct sets count 1, indirect sets count 0.5. That is + /// not a house convention — the 2025 dose-response meta-regression tested exactly this choice + /// against counting indirect sets as 1 and as 0, and the evidence was strongest for 0.5, which + /// its primary models then used. The reference doses NOOP displays come from those models, so + /// the count and the reference must stay on the same method. + /// + /// `direct` and `indirect` are returned alongside so the arithmetic is inspectable rather than + /// asserted. + /// + /// **Warm-ups are excluded; nothing else is.** In particular this does NOT filter by RPE, even + /// though a hard set is the thing that drives adaptation — because the reference doses were + /// derived from unfiltered working-set counts, and filtering here would quietly compare a + /// smaller number against a scale built from a larger one. Proximity to failure is reported + /// separately by `liftRpeProfile` instead, where it can inform without corrupting the count. + public func liftSetCounts( + deviceId: String, + fromTs: Int, + toTs: Int + ) async throws -> (fractional: [LiftMuscle: Double], direct: [LiftMuscle: Int], indirect: [LiftMuscle: Int]) { + try syncRead { db in + let rows = try Row.fetchAll(db, sql: """ + SELECT s.primaryMuscle AS primaryMuscle, s.secondaryMuscles AS secondaryMuscles + FROM liftSet s + JOIN liftSession sess ON sess.id = s.sessionId + WHERE s.deviceId = ? + AND sess.startTs >= ? AND sess.startTs <= ? + AND s.isWarmup = 0 + """, arguments: [deviceId, fromTs, toTs]) + + var direct: [LiftMuscle: Int] = [:] + var indirect: [LiftMuscle: Int] = [:] + for row in rows { + if let token: String = row["primaryMuscle"], let m = LiftMuscle(rawValue: token) { + direct[m, default: 0] += 1 + } + for m in LiftMuscle.decodeList(row["secondaryMuscles"]) { + indirect[m, default: 0] += 1 + } + } + var fractional: [LiftMuscle: Double] = [:] + for (m, n) in direct { fractional[m, default: 0] += Double(n) * LiftMuscle.directSetCredit } + for (m, n) in indirect { fractional[m, default: 0] += Double(n) * LiftMuscle.indirectSetCredit } + return (fractional, direct, indirect) + } + } + + /// How hard the working sets in a window actually were, reported separately from the counts. + /// + /// Proximity to failure is what makes a set count biologically, but it is NOT folded into + /// `liftSetCounts` — see that method for why. Sets with no RPE recorded are excluded from the + /// average and reported as `unrated`, rather than being silently treated as easy or as hard. + public func liftRpeProfile( + deviceId: String, + fromTs: Int, + toTs: Int, + hardThreshold: Double = 7 + ) async throws -> (workingSets: Int, rated: Int, unrated: Int, meanRpe: Double?, atOrAboveThreshold: Int) { + try syncRead { db in + let rows = try Row.fetchAll(db, sql: """ + SELECT s.rpe AS rpe + FROM liftSet s + JOIN liftSession sess ON sess.id = s.sessionId + WHERE s.deviceId = ? + AND sess.startTs >= ? AND sess.startTs <= ? + AND s.isWarmup = 0 + """, arguments: [deviceId, fromTs, toTs]) + + var rated: [Double] = [] + var unrated = 0 + for row in rows { + if let v: Double = row["rpe"] { rated.append(v) } else { unrated += 1 } + } + let mean = rated.isEmpty ? nil : rated.reduce(0, +) / Double(rated.count) + return (workingSets: rows.count, + rated: rated.count, + unrated: unrated, + meanRpe: mean, + atOrAboveThreshold: rated.filter { $0 >= hardThreshold }.count) + } + } + + /// Distinct exercise names this device has ever logged, alphabetical — the suggestion list for + /// the program editor, built from the user's own history rather than a shipped catalogue. + public func liftExercisesLogged(deviceId: String) async throws -> [String] { + try syncRead { db in + try String.fetchAll(db, sql: """ + SELECT DISTINCT exercise FROM liftSet + WHERE deviceId = ? + ORDER BY exercise ASC + """, arguments: [deviceId]) + } + } +} diff --git a/Packages/WhoopStore/Sources/WhoopStore/LiftMuscle.swift b/Packages/WhoopStore/Sources/WhoopStore/LiftMuscle.swift new file mode 100644 index 0000000000..68d6f6e4d9 --- /dev/null +++ b/Packages/WhoopStore/Sources/WhoopStore/LiftMuscle.swift @@ -0,0 +1,126 @@ +import Foundation + +// MARK: - The muscle-group vocabulary (v45) +// +// Exercise NAMES are free text — whatever the user types, kept verbatim. Muscle GROUPS are not: +// they are a closed, fixed set of canonical tokens, because a per-muscle rollup is only meaningful +// if the same muscle always lands in the same bucket. Free-text groups would scatter "Quads", +// "quads" and "Legs" across three counts and quietly make the weekly view a lie. +// +// These raw values are a STORED-DATA CONTRACT. They are written into `liftExercise.primaryMuscle`, +// `liftExercise.secondaryMuscles`, `liftSet.primaryMuscle` and `liftSet.secondaryMuscles`, and an +// Android twin must use byte-identical tokens. So: +// • NEVER rename or remove a case — stored rows would stop resolving. +// • Adding a case is safe and additive. +// • Tokens are deliberately locale-independent; the display name is localized at the app layer, +// never here (WhoopStore holds no UI strings). +// +// Granularity matches the resolution the volume literature measures at — the muscles hypertrophy +// trials actually image (pectoralis, latissimus, the deltoid heads, biceps, triceps, quadriceps, +// hamstrings, glutes, erectors, gastrocnemius…), plus the few that are trained in practice but +// rarely studied (adductors, abductors, forearms, neck). Coarser ("Legs") would hide hamstrings +// that never got trained; finer (individual heads of each muscle) would make every bucket look +// starved and turn logging into taxonomy homework. +// +// There is deliberately NO "full body" and NO "other". Both are escape hatches that contribute to +// no muscle's count while looking like they did — a clean is quads-direct with upper back, traps, +// glutes and hamstrings indirect, and saying so is both more accurate and more useful. An exercise +// the user has not classified simply has a nil primary: it still counts toward volume and session +// load, it just does not claim a muscle it was never assigned. + +/// A muscle group a set can be attributed to. Raw values are stored; do not rename them. +public enum LiftMuscle: String, CaseIterable, Codable, Sendable { + + // Push + case chest + case frontDelts + case sideDelts + case rearDelts + case triceps + + // Pull + case lats + case upperBack + case traps + case biceps + case forearms + + // Legs + case quads + case hamstrings + case glutes + case adductors + case abductors + case calves + + // Trunk + case abs + case obliques + case lowerBack + case neck + + /// Coarse section, used only to group the picker. Not stored, not counted — purely presentation + /// scaffolding, so changing it is free. + public enum Region: String, CaseIterable, Sendable { + case push, pull, legs, trunk + } + + public var region: Region { + switch self { + case .chest, .frontDelts, .sideDelts, .rearDelts, .triceps: + return .push + case .lats, .upperBack, .traps, .biceps, .forearms: + return .pull + case .quads, .hamstrings, .glutes, .adductors, .abductors, .calves: + return .legs + case .abs, .obliques, .lowerBack, .neck: + return .trunk + } + } + + /// Cases in picker order: by region, in the order declared above. + public static var ordered: [LiftMuscle] { allCases } + + /// Cases in one region, in declaration order. + public static func inRegion(_ region: Region) -> [LiftMuscle] { + allCases.filter { $0.region == region } + } + + /// What one set contributes to this muscle's weekly count. + /// + /// The 2025 Sports Medicine dose-response meta-regression compared three ways of counting a set + /// for a muscle that was only an indirect mover — "total" (count it as 1), "fractional" (count + /// it as 0.5) and "direct" (count it as 0) — and found the evidence strongest for FRACTIONAL, + /// which is what its primary models use. So 0.5 here is not a house convention; it is the + /// operationalisation with the best empirical support, and the reference doses NOOP shows are + /// derived under it. Change one and you must change the other. + public static let directSetCredit: Double = 1.0 + public static let indirectSetCredit: Double = 0.5 + + // MARK: - The secondary-muscle list, as stored + // + // A comma-joined token list in one TEXT column rather than a join table: the list is short, + // never queried on its own, and one column is trivially mirrorable in Room. Order is preserved + // as the user set it; duplicates and the primary itself are stripped on encode so a set can + // never be counted twice for one muscle. + + /// Encode a secondary list for storage. Returns nil for an empty list so the column stays NULL + /// rather than holding an empty string (two spellings of "none" is a bug waiting to happen). + public static func encodeList(_ muscles: [LiftMuscle], excluding primary: LiftMuscle? = nil) -> String? { + var seen = Set() + if let primary { seen.insert(primary) } + var kept: [LiftMuscle] = [] + for m in muscles where !seen.contains(m) { + seen.insert(m) + kept.append(m) + } + return kept.isEmpty ? nil : kept.map(\.rawValue).joined(separator: ",") + } + + /// Decode a stored secondary list. Unknown tokens are skipped rather than failing the read: a + /// database written by a newer build must stay readable by an older one. + public static func decodeList(_ stored: String?) -> [LiftMuscle] { + guard let stored, !stored.isEmpty else { return [] } + return stored.split(separator: ",").compactMap { LiftMuscle(rawValue: String($0)) } + } +} diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift new file mode 100644 index 0000000000..086a46a42d --- /dev/null +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift @@ -0,0 +1,526 @@ +import XCTest +import GRDB +@testable import WhoopStore + +final class LiftLogStoreTests: XCTestCase { + + // MARK: - v46 migration (additive: five new tables + indexes, nothing dropped) + + func testV46CreatesLiftLogTables() async throws { + let store = try await WhoopStore.inMemory() + let tables = try await store.tableNames() + for t in ["liftExercise", "liftProgram", "liftProgramItem", "liftSession", "liftSet"] { + XCTAssertTrue(tables.contains(t), "missing table \(t)") + let pk = try await store.primaryKeyColumns(t) + XCTAssertEqual(pk, ["id"], "\(t) primary key should be id") + } + } + + func testV46ColumnsArePresent() async throws { + let store = try await WhoopStore.inMemory() + + let exercise = try await store.columnNamesForTest(table: "liftExercise") + for c in ["id", "deviceId", "name", "primaryMuscle", "secondaryMuscles", + "createdAt", "lastUsedTs"] { + XCTAssertTrue(exercise.contains(c), "liftExercise missing column \(c)") + } + + let program = try await store.columnNamesForTest(table: "liftProgram") + for c in ["id", "deviceId", "name", "note", "createdAt", "updatedAt", "archived"] { + XCTAssertTrue(program.contains(c), "liftProgram missing column \(c)") + } + + let item = try await store.columnNamesForTest(table: "liftProgramItem") + for c in ["id", "deviceId", "programId", "ord", "exercise", "targetSets", + "targetRepsLow", "targetRepsHigh", "targetRpe", "targetWeightKg", + "restSec", "note"] { + XCTAssertTrue(item.contains(c), "liftProgramItem missing column \(c)") + } + + let session = try await store.columnNamesForTest(table: "liftSession") + for c in ["id", "deviceId", "startTs", "endTs", "sport", "programId", "programName", + "sessionRpe", "note"] { + XCTAssertTrue(session.contains(c), "liftSession missing column \(c)") + } + + let set = try await store.columnNamesForTest(table: "liftSet") + for c in ["id", "deviceId", "sessionId", "ord", "exercise", "primaryMuscle", + "secondaryMuscles", "setIndex", "weightKg", "reps", "rpe", "isWarmup", + "startTs", "endTs", "restSec", "note"] { + XCTAssertTrue(set.contains(c), "liftSet missing column \(c)") + } + } + + func testV46CreatesIndexes() async throws { + let store = try await WhoopStore.inMemory() + let exercise = try await store.indexNamesForTest(table: "liftExercise") + XCTAssertTrue(exercise.contains("idx_liftExercise_natural")) + + let program = try await store.indexNamesForTest(table: "liftProgram") + XCTAssertTrue(program.contains("idx_liftProgram_device_updatedAt")) + + let item = try await store.indexNamesForTest(table: "liftProgramItem") + XCTAssertTrue(item.contains("idx_liftProgramItem_device")) + XCTAssertTrue(item.contains("idx_liftProgramItem_program_ord")) + + let session = try await store.indexNamesForTest(table: "liftSession") + XCTAssertTrue(session.contains("idx_liftSession_natural")) + + let set = try await store.indexNamesForTest(table: "liftSet") + XCTAssertTrue(set.contains("idx_liftSet_device_exercise")) + XCTAssertTrue(set.contains("idx_liftSet_session_ord")) + } + + /// Additive: v46 must not drop any table that existed before it. + func testV46IsAdditive() async throws { + let store = try await WhoopStore.inMemory() + let tables = try await store.tableNames() + for t in ["device", "hrSample", "rrInterval", "event", "battery", "rawBatch", + "sleepSession", "dailyMetric", "journal", "workout", "appleDaily", + "metricSeries", "pairedDevice", "dayOwnership", "labMarker", "appleStepHour"] { + XCTAssertTrue(tables.contains(t), "v46 must not drop \(t)") + } + } + + // MARK: - The user's own exercise vocabulary + + /// Anything the user types becomes an exercise they can reuse, with the muscle group they gave + /// it. NOOP ships no catalogue, so this table IS the catalogue. + func testCustomExerciseIsRememberedForReuse() async throws { + let store = try await WhoopStore.inMemory() + let invented = LiftExerciseRow(id: "e1", deviceId: dev, name: "Sissy Squat on the Smith", + primaryMuscle: .quads, secondaryMuscles: [.glutes], + createdAt: 100, lastUsedTs: 100) + let written = try await store.upsertLiftExercises([invented]) + XCTAssertEqual(written, 1) + + let vocabulary = try await store.liftExercises(deviceId: dev) + XCTAssertEqual(vocabulary.map(\.name), ["Sissy Squat on the Smith"], + "the name is kept exactly as typed, not normalised") + XCTAssertEqual(vocabulary.first?.primaryMuscle, .quads) + XCTAssertEqual(vocabulary.first?.secondaryMuscles, [.glutes]) + } + + /// Using an exercise again must update its recency, never duplicate it — and must never wipe the + /// muscle group just because the caller did not resupply one. + func testReusingAnExerciseUpdatesRecencyAndKeepsItsMuscleGroup() async throws { + let store = try await WhoopStore.inMemory() + _ = try await store.upsertLiftExercises([ + LiftExerciseRow(id: "e1", deviceId: dev, name: "Leg Press", + primaryMuscle: .quads, secondaryMuscles: [.glutes], + createdAt: 100, lastUsedTs: 100), + ]) + // Used again later, by a caller that only knows the name. + _ = try await store.upsertLiftExercises([ + LiftExerciseRow(id: "fresh-id", deviceId: dev, name: "Leg Press", + primaryMuscle: nil, createdAt: 900, lastUsedTs: 900), + ]) + + let vocabulary = try await store.liftExercises(deviceId: dev) + XCTAssertEqual(vocabulary.count, 1, "same (deviceId, name) must not duplicate") + XCTAssertEqual(vocabulary.first?.primaryMuscle, .quads, "an absent group must not erase the set one") + XCTAssertEqual(vocabulary.first?.lastUsedTs, 900) + XCTAssertEqual(vocabulary.first?.id, "e1", "the original id survives") + } + + /// Recently used first, then never-used, alphabetical within each — the picker's order. + func testExercisePickerOrdersByRecencyThenName() async throws { + let store = try await WhoopStore.inMemory() + _ = try await store.upsertLiftExercises([ + LiftExerciseRow(id: "e1", deviceId: dev, name: "Zercher Squat", + primaryMuscle: nil, createdAt: 1, lastUsedTs: 500), + LiftExerciseRow(id: "e2", deviceId: dev, name: "Pec Deck", + primaryMuscle: nil, createdAt: 1, lastUsedTs: nil), + LiftExerciseRow(id: "e3", deviceId: dev, name: "Dead Bug", + primaryMuscle: nil, createdAt: 1, lastUsedTs: nil), + ]) + let vocabulary = try await store.liftExercises(deviceId: dev) + XCTAssertEqual(vocabulary.map(\.name), ["Zercher Squat", "Dead Bug", "Pec Deck"]) + } + + /// Forgetting an exercise from the vocabulary must not rewrite the sets already logged under it. + func testForgettingAnExerciseKeepsLoggedSets() async throws { + let store = try await WhoopStore.inMemory() + _ = try await store.upsertLiftExercises([ + LiftExerciseRow(id: "e1", deviceId: dev, name: "Leg Press", + primaryMuscle: .quads, secondaryMuscles: [.glutes], + createdAt: 100, lastUsedTs: 100), + ]) + _ = try await store.upsertLiftSessions([mkSession(id: "s1", startTs: 1_000)]) + _ = try await store.upsertLiftSets([mkSet(id: "x1", sessionId: "s1", ord: 0, setIndex: 1)]) + + let forgotten = try await store.deleteLiftExercise(id: "e1") + XCTAssertTrue(forgotten) + let stillThere = try await store.liftSets(sessionId: "s1") + XCTAssertEqual(stillThere.map(\.exercise), ["Leg Press"]) + XCTAssertEqual(stillThere.first?.primaryMuscle, .quads, + "the set carries its own copy, so history keeps its meaning") + } + + // MARK: - Programs + + func testProgramRoundTripAndArchiveFilter() async throws { + let store = try await WhoopStore.inMemory() + let live = mkProgram(id: "p1", name: "Upper A", updatedAt: 200) + let old = mkProgram(id: "p2", name: "Old split", updatedAt: 100, archived: true) + let written = try await store.upsertLiftPrograms([live, old]) + XCTAssertEqual(written, 2) + + let visible = try await store.liftPrograms(deviceId: dev) + XCTAssertEqual(visible.map(\.id), ["p1"], "archived programs stay out of the picker") + + let all = try await store.liftPrograms(deviceId: dev, includeArchived: true) + XCTAssertEqual(all.map(\.id), ["p1", "p2"], "most recently touched first") + XCTAssertEqual(all.first, live) + } + + func testProgramUpsertIsIdempotentById() async throws { + let store = try await WhoopStore.inMemory() + _ = try await store.upsertLiftPrograms([mkProgram(id: "p1", name: "Upper A", updatedAt: 100)]) + _ = try await store.upsertLiftPrograms([mkProgram(id: "p1", name: "Upper A2", updatedAt: 300)]) + + let all = try await store.liftPrograms(deviceId: dev) + XCTAssertEqual(all.count, 1, "same id updates in place") + XCTAssertEqual(all.first?.name, "Upper A2") + XCTAssertEqual(all.first?.updatedAt, 300) + } + + func testReplaceProgramItemsSwapsTheWholeList() async throws { + let store = try await WhoopStore.inMemory() + _ = try await store.upsertLiftPrograms([mkProgram(id: "p1", name: "Upper A", updatedAt: 100)]) + _ = try await store.replaceLiftProgramItems(programId: "p1", items: [ + mkItem(id: "i1", ord: 0, exercise: "Incline Machine Press"), + mkItem(id: "i2", ord: 1, exercise: "Chest-Supported Row"), + ]) + let firstPass = try await store.liftProgramItems(programId: "p1") + XCTAssertEqual(firstPass.map(\.exercise), ["Incline Machine Press", "Chest-Supported Row"]) + + // A reorder that also drops a line: replace wholesale, no stale rows left behind. + _ = try await store.replaceLiftProgramItems(programId: "p1", items: [ + mkItem(id: "i2", ord: 0, exercise: "Chest-Supported Row"), + ]) + let secondPass = try await store.liftProgramItems(programId: "p1") + XCTAssertEqual(secondPass.map(\.id), ["i2"]) + } + + func testDeletingAProgramKeepsItsSessions() async throws { + let store = try await WhoopStore.inMemory() + _ = try await store.upsertLiftPrograms([mkProgram(id: "p1", name: "Upper A", updatedAt: 100)]) + _ = try await store.replaceLiftProgramItems(programId: "p1", items: [mkItem(id: "i1", ord: 0)]) + _ = try await store.upsertLiftSessions([mkSession(id: "s1", startTs: 1_000, programId: "p1")]) + + let deleted = try await store.deleteLiftProgram(id: "p1") + XCTAssertTrue(deleted) + let orphanedItems = try await store.liftProgramItems(programId: "p1") + XCTAssertTrue(orphanedItems.isEmpty) + + // History survives: the session kept its own snapshot of the name it ran under. + let sessions = try await store.liftSessions(deviceId: dev, fromTs: 0, toTs: 9_999) + XCTAssertEqual(sessions.count, 1) + XCTAssertEqual(sessions.first?.programName, "Upper A") + } + + // MARK: - Sessions + + /// The session is keyed to its workout row by (deviceId, startTs, sport), so re-saving the same + /// session updates it in place even when the caller mints a fresh id. + func testSessionUpsertIsIdempotentByNaturalKey() async throws { + let store = try await WhoopStore.inMemory() + _ = try await store.upsertLiftSessions([mkSession(id: "s1", startTs: 1_000, endTs: nil)]) + _ = try await store.upsertLiftSessions([mkSession(id: "different-id", startTs: 1_000, endTs: 4_600)]) + + let sessions = try await store.liftSessions(deviceId: dev, fromTs: 0, toTs: 9_999) + XCTAssertEqual(sessions.count, 1, "same (deviceId, startTs, sport) must not duplicate") + XCTAssertEqual(sessions.first?.endTs, 4_600, "the finish time landed on the existing row") + XCTAssertEqual(sessions.first?.id, "s1", "the original id is kept") + } + + func testSessionLookupByWorkoutNaturalKey() async throws { + let store = try await WhoopStore.inMemory() + _ = try await store.upsertLiftSessions([mkSession(id: "s1", startTs: 1_000)]) + + let hit = try await store.liftSession(deviceId: dev, startTs: 1_000, sport: sport) + XCTAssertEqual(hit?.id, "s1") + let miss = try await store.liftSession(deviceId: dev, startTs: 2_000, sport: sport) + XCTAssertNil(miss, "a workout not logged through the lift log has no session") + } + + func testDeletingASessionDeletesItsSets() async throws { + let store = try await WhoopStore.inMemory() + _ = try await store.upsertLiftSessions([mkSession(id: "s1", startTs: 1_000)]) + _ = try await store.upsertLiftSets([ + mkSet(id: "x1", sessionId: "s1", ord: 0, setIndex: 1), + mkSet(id: "x2", sessionId: "s1", ord: 1, setIndex: 2), + ]) + let removed = try await store.deleteLiftSession(id: "s1") + XCTAssertTrue(removed) + let orphanedSets = try await store.liftSets(sessionId: "s1") + XCTAssertTrue(orphanedSets.isEmpty) + } + + // MARK: - Sets + + func testSetsReadBackInPerformedOrder() async throws { + let store = try await WhoopStore.inMemory() + _ = try await store.upsertLiftSessions([mkSession(id: "s1", startTs: 1_000)]) + // Inserted out of order on purpose: the read must sort by ord, not by insertion. + _ = try await store.upsertLiftSets([ + mkSet(id: "x2", sessionId: "s1", ord: 1, setIndex: 2, weightKg: 62.5, reps: 9), + mkSet(id: "x1", sessionId: "s1", ord: 0, setIndex: 1, weightKg: 60, reps: 10), + ]) + let sets = try await store.liftSets(sessionId: "s1") + XCTAssertEqual(sets.map(\.id), ["x1", "x2"]) + XCTAssertEqual(sets.map(\.setIndex), [1, 2]) + } + + func testVolumeIsWeightTimesRepsAndExcludesWarmups() { + let working = mkSet(id: "w", sessionId: "s1", ord: 0, setIndex: 1, weightKg: 60, reps: 10) + XCTAssertEqual(working.volumeKg, 600) + + var warmup = working + warmup.isWarmup = true + XCTAssertNil(warmup.volumeKg, "warmup sets are recorded but never counted as volume") + + var bodyweight = working + bodyweight.weightKg = nil + XCTAssertNil(bodyweight.volumeKg, "no weight means no volume figure, not a zero") + } + + // MARK: - The read the feature exists for + + func testLastLiftSetsReturnsTheMostRecentSessionForThatExercise() async throws { + let store = try await WhoopStore.inMemory() + _ = try await store.upsertLiftSessions([ + mkSession(id: "old", startTs: 1_000), + mkSession(id: "recent", startTs: 5_000), + mkSession(id: "newest", startTs: 9_000), + ]) + _ = try await store.upsertLiftSets([ + mkSet(id: "a", sessionId: "old", ord: 0, setIndex: 1, exercise: "Leg Press", weightKg: 100, reps: 10), + mkSet(id: "b", sessionId: "recent", ord: 0, setIndex: 1, exercise: "Leg Press", weightKg: 110, reps: 10), + mkSet(id: "c", sessionId: "recent", ord: 1, setIndex: 2, exercise: "Leg Press", weightKg: 110, reps: 9), + // A different exercise in a later session must not shadow the Leg Press history. + mkSet(id: "d", sessionId: "newest", ord: 0, setIndex: 1, exercise: "Lat Pulldown", weightKg: 55, reps: 10), + ]) + + let last = try await store.lastLiftSets(deviceId: dev, exercise: "Leg Press") + XCTAssertEqual(last.map(\.id), ["b", "c"], "the latest session that actually contained it") + XCTAssertEqual(last.first?.weightKg, 110) + + // `before` excludes the session in progress, so a running session never pre-fills from itself. + let previous = try await store.lastLiftSets(deviceId: dev, exercise: "Leg Press", before: 5_000) + XCTAssertEqual(previous.map(\.id), ["a"]) + + let never = try await store.lastLiftSets(deviceId: dev, exercise: "Nordic Curl") + XCTAssertTrue(never.isEmpty, "an exercise never logged has no history, and that is not an error") + } + + func testLoggedExercisesAreDistinctAndSorted() async throws { + let store = try await WhoopStore.inMemory() + _ = try await store.upsertLiftSessions([mkSession(id: "s1", startTs: 1_000)]) + _ = try await store.upsertLiftSets([ + mkSet(id: "a", sessionId: "s1", ord: 0, setIndex: 1, exercise: "Leg Press"), + mkSet(id: "b", sessionId: "s1", ord: 1, setIndex: 2, exercise: "Leg Press"), + mkSet(id: "c", sessionId: "s1", ord: 2, setIndex: 1, exercise: "Dead Bug"), + ]) + let logged = try await store.liftExercisesLogged(deviceId: dev) + XCTAssertEqual(logged, ["Dead Bug", "Leg Press"]) + } + + // MARK: - Muscle classification + + /// The token set is a stored-data contract: renaming a case would orphan every row written + /// under the old spelling. This test is the tripwire — if it fails, someone renamed a case and + /// needs a migration, not a fix to the test. + func testMuscleTokensAreStable() { + XCTAssertEqual(LiftMuscle.chest.rawValue, "chest") + XCTAssertEqual(LiftMuscle.frontDelts.rawValue, "frontDelts") + XCTAssertEqual(LiftMuscle.upperBack.rawValue, "upperBack") + XCTAssertEqual(LiftMuscle.lowerBack.rawValue, "lowerBack") + XCTAssertEqual(LiftMuscle.allCases.count, 20) + XCTAssertEqual(Set(LiftMuscle.allCases.map(\.rawValue)).count, LiftMuscle.allCases.count, + "tokens must be unique") + } + + func testEveryMuscleHasARegionAndEveryRegionHasMuscles() { + for region in LiftMuscle.Region.allCases { + XCTAssertFalse(LiftMuscle.inRegion(region).isEmpty, "\(region) has no muscles") + } + let regioned = LiftMuscle.Region.allCases.flatMap(LiftMuscle.inRegion) + XCTAssertEqual(Set(regioned), Set(LiftMuscle.allCases), + "every muscle must appear in exactly one region") + XCTAssertEqual(regioned.count, LiftMuscle.allCases.count) + } + + /// A set must never be counted twice for one muscle, so the primary is stripped out of the + /// secondary list, as are duplicates. Order the user chose is otherwise preserved. + func testSecondaryListDropsThePrimaryAndDuplicates() { + let encoded = LiftMuscle.encodeList([.glutes, .quads, .glutes, .hamstrings], excluding: .quads) + XCTAssertEqual(encoded, "glutes,hamstrings") + XCTAssertNil(LiftMuscle.encodeList([], excluding: nil), "empty stores as NULL, not \"\"") + XCTAssertNil(LiftMuscle.encodeList([.quads], excluding: .quads)) + } + + /// A database written by a newer build must stay readable by an older one, so an unrecognised + /// token is skipped rather than failing the whole read. + func testUnknownMuscleTokensAreSkippedNotFatal() { + XCTAssertEqual(LiftMuscle.decodeList("glutes,serratusMagnificus,calves"), [.glutes, .calves]) + XCTAssertEqual(LiftMuscle.decodeList(nil), []) + XCTAssertEqual(LiftMuscle.decodeList(""), []) + } + + func testSetClassificationRoundTrips() async throws { + let store = try await WhoopStore.inMemory() + _ = try await store.upsertLiftSessions([mkSession(id: "s1", startTs: 1_000)]) + _ = try await store.upsertLiftSets([ + mkSet(id: "x1", sessionId: "s1", ord: 0, setIndex: 1, + primary: .chest, secondary: [.frontDelts, .triceps]), + ]) + let read = try await store.liftSets(sessionId: "s1") + XCTAssertEqual(read.first?.primaryMuscle, .chest) + XCTAssertEqual(read.first?.secondaryMuscles, [.frontDelts, .triceps]) + } + + // MARK: - Per-muscle set counts (the fractional method) + + /// Direct sets count 1, indirect count 0.5 — the operationalisation the 2025 dose-response + /// meta-regression found best supported, and the one its reference doses were derived under. + /// The components are returned too, so the arithmetic can be checked rather than trusted. + func testSetCountsUseTheFractionalMethod() async throws { + let store = try await WhoopStore.inMemory() + _ = try await store.upsertLiftSessions([mkSession(id: "s1", startTs: 1_000)]) + _ = try await store.upsertLiftSets([ + mkSet(id: "a", sessionId: "s1", ord: 0, setIndex: 1, primary: .quads, secondary: [.glutes]), + mkSet(id: "b", sessionId: "s1", ord: 1, setIndex: 2, primary: .quads, secondary: [.glutes]), + mkSet(id: "c", sessionId: "s1", ord: 2, setIndex: 1, primary: .glutes, secondary: []), + ]) + let counts = try await store.liftSetCounts(deviceId: dev, fromTs: 0, toTs: 9_999) + + XCTAssertEqual(counts.direct[.quads], 2) + XCTAssertEqual(counts.fractional[.quads] ?? 0, 2.0, accuracy: 0.0001) + + // Glutes: one direct set (1.0) plus two indirect (0.5 each) = 2.0 + XCTAssertEqual(counts.direct[.glutes], 1) + XCTAssertEqual(counts.indirect[.glutes], 2) + XCTAssertEqual(counts.fractional[.glutes] ?? 0, 2.0, accuracy: 0.0001) + } + + func testTheFractionalCreditsMatchThePublishedMethod() { + XCTAssertEqual(LiftMuscle.directSetCredit, 1.0) + XCTAssertEqual(LiftMuscle.indirectSetCredit, 0.5, + "0.5 is the meta-regression's method, not a house convention — changing it " + + "invalidates the reference doses shown beside it") + } + + /// Warm-ups are excluded. RPE is NOT filtered here: the reference doses were derived from + /// unfiltered working-set counts, so filtering would compare a smaller number against a scale + /// built from a larger one. + func testSetCountsExcludeWarmupsAndDoNotFilterByRpe() async throws { + let store = try await WhoopStore.inMemory() + _ = try await store.upsertLiftSessions([mkSession(id: "s1", startTs: 1_000)]) + _ = try await store.upsertLiftSets([ + mkSet(id: "warm", sessionId: "s1", ord: 0, setIndex: 1, primary: .chest, + secondary: [], rpe: 9, isWarmup: true), + mkSet(id: "easy", sessionId: "s1", ord: 1, setIndex: 2, primary: .chest, + secondary: [], rpe: 4), + mkSet(id: "hard", sessionId: "s1", ord: 2, setIndex: 3, primary: .chest, + secondary: [], rpe: 9), + mkSet(id: "unrated", sessionId: "s1", ord: 3, setIndex: 4, primary: .chest, + secondary: [], rpe: nil), + ]) + let counts = try await store.liftSetCounts(deviceId: dev, fromTs: 0, toTs: 9_999) + XCTAssertEqual(counts.direct[.chest], 3, "warm-up excluded; easy and unrated still count") + } + + func testSetCountsRespectTheWindow() async throws { + let store = try await WhoopStore.inMemory() + _ = try await store.upsertLiftSessions([ + mkSession(id: "inside", startTs: 5_000), + mkSession(id: "outside", startTs: 50_000), + ]) + _ = try await store.upsertLiftSets([ + mkSet(id: "a", sessionId: "inside", ord: 0, setIndex: 1, primary: .lats, secondary: []), + mkSet(id: "b", sessionId: "outside", ord: 0, setIndex: 1, primary: .lats, secondary: []), + ]) + let counts = try await store.liftSetCounts(deviceId: dev, fromTs: 0, toTs: 9_999) + XCTAssertEqual(counts.direct[.lats], 1) + } + + // MARK: - Proximity to failure, reported separately + + /// An unrated set is neither counted as hard nor assumed easy — it is reported as unrated, and + /// left out of the mean. Guessing in either direction would be inventing data. + func testRpeProfileSeparatesRatedFromUnrated() async throws { + let store = try await WhoopStore.inMemory() + _ = try await store.upsertLiftSessions([mkSession(id: "s1", startTs: 1_000)]) + _ = try await store.upsertLiftSets([ + mkSet(id: "a", sessionId: "s1", ord: 0, setIndex: 1, rpe: 6), + mkSet(id: "b", sessionId: "s1", ord: 1, setIndex: 2, rpe: 8), + mkSet(id: "c", sessionId: "s1", ord: 2, setIndex: 3, rpe: nil), + mkSet(id: "warm", sessionId: "s1", ord: 3, setIndex: 4, rpe: 9, isWarmup: true), + ]) + let profile = try await store.liftRpeProfile(deviceId: dev, fromTs: 0, toTs: 9_999) + XCTAssertEqual(profile.workingSets, 3, "the warm-up is not a working set") + XCTAssertEqual(profile.rated, 2) + XCTAssertEqual(profile.unrated, 1) + XCTAssertEqual(profile.meanRpe ?? 0, 7.0, accuracy: 0.0001) + XCTAssertEqual(profile.atOrAboveThreshold, 1) + } + + func testRpeProfileWithNothingRatedHasNoMean() async throws { + let store = try await WhoopStore.inMemory() + _ = try await store.upsertLiftSessions([mkSession(id: "s1", startTs: 1_000)]) + _ = try await store.upsertLiftSets([mkSet(id: "a", sessionId: "s1", ord: 0, setIndex: 1, rpe: nil)]) + let profile = try await store.liftRpeProfile(deviceId: dev, fromTs: 0, toTs: 9_999) + XCTAssertNil(profile.meanRpe, "no ratings means no average, not zero") + XCTAssertEqual(profile.unrated, 1) + } + + // MARK: - Privacy: delete-means-gone + + /// Every lift table is deviceId-keyed and listed in `deviceScopedTables`, so forgetting a device + /// clears the whole feature. The child tables (items, sets) are the ones a foreign-key-less schema + /// would otherwise strand. + func testLiftTablesAreDeviceScoped() { + for t in ["liftExercise", "liftProgram", "liftProgramItem", "liftSession", "liftSet"] { + XCTAssertTrue(DeviceRegistryStore.deviceScopedTables.contains(t), + "\(t) missing from deviceScopedTables — deleteAllData would leave it behind") + } + } + + // MARK: - Helpers + + private let dev = "my-whoop" + private let sport = "Strength Training" + + private func mkProgram(id: String, name: String, updatedAt: Int, + archived: Bool = false) -> LiftProgramRow { + LiftProgramRow(id: id, deviceId: dev, name: name, note: nil, + createdAt: 1, updatedAt: updatedAt, archived: archived) + } + + private func mkItem(id: String, ord: Int, exercise: String = "Leg Press", + programId: String = "p1") -> LiftProgramItemRow { + LiftProgramItemRow(id: id, deviceId: dev, programId: programId, ord: ord, + exercise: exercise, targetSets: 3, + targetRepsLow: 8, targetRepsHigh: 10, targetRpe: 7.5, + restSec: 180, note: "Lower slowly.") + } + + private func mkSession(id: String, startTs: Int, endTs: Int? = nil, + programId: String? = "p1") -> LiftSessionRow { + LiftSessionRow(id: id, deviceId: dev, startTs: startTs, endTs: endTs, sport: sport, + programId: programId, programName: "Upper A", note: nil) + } + + private func mkSet(id: String, sessionId: String, ord: Int, setIndex: Int, + exercise: String = "Leg Press", weightKg: Double? = 60, + reps: Int? = 10, primary: LiftMuscle? = .quads, + secondary: [LiftMuscle] = [.glutes], rpe: Double? = 8, + isWarmup: Bool = false) -> LiftSetRow { + LiftSetRow(id: id, deviceId: dev, sessionId: sessionId, ord: ord, exercise: exercise, + primaryMuscle: primary, secondaryMuscles: secondary, setIndex: setIndex, + weightKg: weightKg, reps: reps, rpe: rpe, isWarmup: isWarmup, + startTs: nil, endTs: nil, restSec: nil, note: nil) + } +} diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json index f48c4f1145..88cfe26de8 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json @@ -46,7 +46,8 @@ "v42-daily-sleep-hr-only", "v43-coach-messages", "v44-ppg-waveform-base-code", - "v45-rr-source-index" + "v45-rr-source-index", + "v46-lift-log" ], "divergenceReasons": { "android-orphan-synced-column": "REAL COLUMN DRIFT: `synced` exists on Android only. GRDB's v10 note is explicit that stepSample gets no `synced` column ('unused; see StreamStore'), and v12's ppgHrSample never had one either; the Room entities copied the flag from the older per-second entities. Nothing reads it on Android \u2014 the per-row upload flag belongs to an upload path that does not exist there \u2014 so it is dead width, not divergent data. Removing it needs a Room table rebuild.", @@ -880,6 +881,423 @@ } ] }, + "liftExercise": { + "platform": "ios_only", + "reason": "The in-app strength log (v46): saved programs and the sessions run from them. Landed on Apple first so the schema can settle against real gym use before it is frozen into a Room twin; the Kotlin side is a tracked follow-up, not a divergence. Nothing here feeds a score, so a device without these tables computes identical metrics.", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "deviceId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "name", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "primaryMuscle", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "secondaryMuscles", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "createdAt", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "lastUsedTs", + "affinity": "INTEGER", + "notNull": false, + "default": null + } + ], + "primaryKey": [ + "id" + ], + "indices": [ + { + "name": "idx_liftExercise_natural", + "unique": true, + "columns": [ + "deviceId", + "name" + ] + } + ] + }, + "liftProgram": { + "platform": "ios_only", + "reason": "The in-app strength log (v46): saved programs and the sessions run from them. Landed on Apple first so the schema can settle against real gym use before it is frozen into a Room twin; the Kotlin side is a tracked follow-up, not a divergence. Nothing here feeds a score, so a device without these tables computes identical metrics.", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "deviceId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "name", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "note", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "createdAt", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "updatedAt", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "archived", + "affinity": "INTEGER", + "notNull": true, + "default": "0" + } + ], + "primaryKey": [ + "id" + ], + "indices": [ + { + "name": "idx_liftProgram_device_updatedAt", + "unique": false, + "columns": [ + "deviceId", + "updatedAt" + ] + } + ] + }, + "liftProgramItem": { + "platform": "ios_only", + "reason": "The in-app strength log (v46): saved programs and the sessions run from them. Landed on Apple first so the schema can settle against real gym use before it is frozen into a Room twin; the Kotlin side is a tracked follow-up, not a divergence. Nothing here feeds a score, so a device without these tables computes identical metrics.", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "deviceId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "programId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "ord", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "exercise", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "targetSets", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "targetRepsLow", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "targetRepsHigh", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "targetRpe", + "affinity": "REAL", + "notNull": false, + "default": null + }, + { + "name": "targetWeightKg", + "affinity": "REAL", + "notNull": false, + "default": null + }, + { + "name": "restSec", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "note", + "affinity": "TEXT", + "notNull": false, + "default": null + } + ], + "primaryKey": [ + "id" + ], + "indices": [ + { + "name": "idx_liftProgramItem_device", + "unique": false, + "columns": [ + "deviceId" + ] + }, + { + "name": "idx_liftProgramItem_program_ord", + "unique": false, + "columns": [ + "programId", + "ord" + ] + } + ] + }, + "liftSession": { + "platform": "ios_only", + "reason": "The in-app strength log (v46): saved programs and the sessions run from them. Landed on Apple first so the schema can settle against real gym use before it is frozen into a Room twin; the Kotlin side is a tracked follow-up, not a divergence. Nothing here feeds a score, so a device without these tables computes identical metrics.", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "deviceId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "startTs", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "endTs", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "sport", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "programId", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "programName", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "sessionRpe", + "affinity": "REAL", + "notNull": false, + "default": null + }, + { + "name": "note", + "affinity": "TEXT", + "notNull": false, + "default": null + } + ], + "primaryKey": [ + "id" + ], + "indices": [ + { + "name": "idx_liftSession_natural", + "unique": true, + "columns": [ + "deviceId", + "startTs", + "sport" + ] + } + ] + }, + "liftSet": { + "platform": "ios_only", + "reason": "The in-app strength log (v46): saved programs and the sessions run from them. Landed on Apple first so the schema can settle against real gym use before it is frozen into a Room twin; the Kotlin side is a tracked follow-up, not a divergence. Nothing here feeds a score, so a device without these tables computes identical metrics.", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "deviceId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "sessionId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "ord", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "exercise", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "primaryMuscle", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "secondaryMuscles", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "setIndex", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "weightKg", + "affinity": "REAL", + "notNull": false, + "default": null + }, + { + "name": "reps", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "rpe", + "affinity": "REAL", + "notNull": false, + "default": null + }, + { + "name": "isWarmup", + "affinity": "INTEGER", + "notNull": true, + "default": "0" + }, + { + "name": "startTs", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "endTs", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "restSec", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "note", + "affinity": "TEXT", + "notNull": false, + "default": null + } + ], + "primaryKey": [ + "id" + ], + "indices": [ + { + "name": "idx_liftSet_device_exercise", + "unique": false, + "columns": [ + "deviceId", + "exercise" + ] + }, + { + "name": "idx_liftSet_session_ord", + "unique": false, + "columns": [ + "sessionId", + "ord" + ] + } + ] + }, "liveSession": { "platform": "both", "columns": [ diff --git a/android/app/src/test/resources/schema_oracle.json b/android/app/src/test/resources/schema_oracle.json index f48c4f1145..88cfe26de8 100644 --- a/android/app/src/test/resources/schema_oracle.json +++ b/android/app/src/test/resources/schema_oracle.json @@ -46,7 +46,8 @@ "v42-daily-sleep-hr-only", "v43-coach-messages", "v44-ppg-waveform-base-code", - "v45-rr-source-index" + "v45-rr-source-index", + "v46-lift-log" ], "divergenceReasons": { "android-orphan-synced-column": "REAL COLUMN DRIFT: `synced` exists on Android only. GRDB's v10 note is explicit that stepSample gets no `synced` column ('unused; see StreamStore'), and v12's ppgHrSample never had one either; the Room entities copied the flag from the older per-second entities. Nothing reads it on Android \u2014 the per-row upload flag belongs to an upload path that does not exist there \u2014 so it is dead width, not divergent data. Removing it needs a Room table rebuild.", @@ -880,6 +881,423 @@ } ] }, + "liftExercise": { + "platform": "ios_only", + "reason": "The in-app strength log (v46): saved programs and the sessions run from them. Landed on Apple first so the schema can settle against real gym use before it is frozen into a Room twin; the Kotlin side is a tracked follow-up, not a divergence. Nothing here feeds a score, so a device without these tables computes identical metrics.", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "deviceId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "name", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "primaryMuscle", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "secondaryMuscles", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "createdAt", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "lastUsedTs", + "affinity": "INTEGER", + "notNull": false, + "default": null + } + ], + "primaryKey": [ + "id" + ], + "indices": [ + { + "name": "idx_liftExercise_natural", + "unique": true, + "columns": [ + "deviceId", + "name" + ] + } + ] + }, + "liftProgram": { + "platform": "ios_only", + "reason": "The in-app strength log (v46): saved programs and the sessions run from them. Landed on Apple first so the schema can settle against real gym use before it is frozen into a Room twin; the Kotlin side is a tracked follow-up, not a divergence. Nothing here feeds a score, so a device without these tables computes identical metrics.", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "deviceId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "name", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "note", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "createdAt", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "updatedAt", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "archived", + "affinity": "INTEGER", + "notNull": true, + "default": "0" + } + ], + "primaryKey": [ + "id" + ], + "indices": [ + { + "name": "idx_liftProgram_device_updatedAt", + "unique": false, + "columns": [ + "deviceId", + "updatedAt" + ] + } + ] + }, + "liftProgramItem": { + "platform": "ios_only", + "reason": "The in-app strength log (v46): saved programs and the sessions run from them. Landed on Apple first so the schema can settle against real gym use before it is frozen into a Room twin; the Kotlin side is a tracked follow-up, not a divergence. Nothing here feeds a score, so a device without these tables computes identical metrics.", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "deviceId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "programId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "ord", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "exercise", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "targetSets", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "targetRepsLow", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "targetRepsHigh", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "targetRpe", + "affinity": "REAL", + "notNull": false, + "default": null + }, + { + "name": "targetWeightKg", + "affinity": "REAL", + "notNull": false, + "default": null + }, + { + "name": "restSec", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "note", + "affinity": "TEXT", + "notNull": false, + "default": null + } + ], + "primaryKey": [ + "id" + ], + "indices": [ + { + "name": "idx_liftProgramItem_device", + "unique": false, + "columns": [ + "deviceId" + ] + }, + { + "name": "idx_liftProgramItem_program_ord", + "unique": false, + "columns": [ + "programId", + "ord" + ] + } + ] + }, + "liftSession": { + "platform": "ios_only", + "reason": "The in-app strength log (v46): saved programs and the sessions run from them. Landed on Apple first so the schema can settle against real gym use before it is frozen into a Room twin; the Kotlin side is a tracked follow-up, not a divergence. Nothing here feeds a score, so a device without these tables computes identical metrics.", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "deviceId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "startTs", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "endTs", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "sport", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "programId", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "programName", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "sessionRpe", + "affinity": "REAL", + "notNull": false, + "default": null + }, + { + "name": "note", + "affinity": "TEXT", + "notNull": false, + "default": null + } + ], + "primaryKey": [ + "id" + ], + "indices": [ + { + "name": "idx_liftSession_natural", + "unique": true, + "columns": [ + "deviceId", + "startTs", + "sport" + ] + } + ] + }, + "liftSet": { + "platform": "ios_only", + "reason": "The in-app strength log (v46): saved programs and the sessions run from them. Landed on Apple first so the schema can settle against real gym use before it is frozen into a Room twin; the Kotlin side is a tracked follow-up, not a divergence. Nothing here feeds a score, so a device without these tables computes identical metrics.", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "deviceId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "sessionId", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "ord", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "exercise", + "affinity": "TEXT", + "notNull": true, + "default": null + }, + { + "name": "primaryMuscle", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "secondaryMuscles", + "affinity": "TEXT", + "notNull": false, + "default": null + }, + { + "name": "setIndex", + "affinity": "INTEGER", + "notNull": true, + "default": null + }, + { + "name": "weightKg", + "affinity": "REAL", + "notNull": false, + "default": null + }, + { + "name": "reps", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "rpe", + "affinity": "REAL", + "notNull": false, + "default": null + }, + { + "name": "isWarmup", + "affinity": "INTEGER", + "notNull": true, + "default": "0" + }, + { + "name": "startTs", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "endTs", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "restSec", + "affinity": "INTEGER", + "notNull": false, + "default": null + }, + { + "name": "note", + "affinity": "TEXT", + "notNull": false, + "default": null + } + ], + "primaryKey": [ + "id" + ], + "indices": [ + { + "name": "idx_liftSet_device_exercise", + "unique": false, + "columns": [ + "deviceId", + "exercise" + ] + }, + { + "name": "idx_liftSet_session_ord", + "unique": false, + "columns": [ + "sessionId", + "ord" + ] + } + ] + }, "liveSession": { "platform": "both", "columns": [ From 12599725cd6df2e2467fc67e2f7d9367d44245f2 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:56:51 +0200 Subject: [PATCH 2/4] store: the Room twin for the lift log schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .../Resources/schema_oracle.json | 47 +++-- .../main/java/com/noop/data/LiftEntities.kt | 194 ++++++++++++++++++ .../main/java/com/noop/data/WhoopDatabase.kt | 100 ++++++++- .../app/src/test/resources/schema_oracle.json | 47 +++-- 4 files changed, 353 insertions(+), 35 deletions(-) create mode 100644 android/app/src/main/java/com/noop/data/LiftEntities.kt diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json index 88cfe26de8..4ab143d2b4 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json @@ -1,6 +1,6 @@ { "_readme": "SHARED Room<->GRDB SCHEMA ORACLE (#775). Two byte-identical copies: Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json and android/app/src/test/resources/schema_oracle.json. SchemaOracleTests.swift compares GRDB's PRAGMA table_info/index_list against it; SchemaOracleTest.kt compares Room's exported schema JSON against it. `columns` is the iOS/GRDB shape in GRDB column order (macOS is the reference implementation); every way Android differs is spelled out in an `android` / `iosAbsent` / `androidColumnOrder` override naming a key in `divergenceReasons`. Adding a column, reordering one, or changing a type/nullability on one platform only fails both suites until it is either fixed or written down here.", - "roomVersion": 39, + "roomVersion": 40, "grdbMigrations": [ "v1", "v2", @@ -882,14 +882,17 @@ ] }, "liftExercise": { - "platform": "ios_only", - "reason": "The in-app strength log (v46): saved programs and the sessions run from them. Landed on Apple first so the schema can settle against real gym use before it is frozen into a Room twin; the Kotlin side is a tracked follow-up, not a divergence. Nothing here feeds a score, so a device without these tables computes identical metrics.", + "platform": "both", "columns": [ { "name": "id", "affinity": "TEXT", "notNull": false, - "default": null + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" }, { "name": "deviceId", @@ -943,14 +946,17 @@ ] }, "liftProgram": { - "platform": "ios_only", - "reason": "The in-app strength log (v46): saved programs and the sessions run from them. Landed on Apple first so the schema can settle against real gym use before it is frozen into a Room twin; the Kotlin side is a tracked follow-up, not a divergence. Nothing here feeds a score, so a device without these tables computes identical metrics.", + "platform": "both", "columns": [ { "name": "id", "affinity": "TEXT", "notNull": false, - "default": null + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" }, { "name": "deviceId", @@ -1004,14 +1010,17 @@ ] }, "liftProgramItem": { - "platform": "ios_only", - "reason": "The in-app strength log (v46): saved programs and the sessions run from them. Landed on Apple first so the schema can settle against real gym use before it is frozen into a Room twin; the Kotlin side is a tracked follow-up, not a divergence. Nothing here feeds a score, so a device without these tables computes identical metrics.", + "platform": "both", "columns": [ { "name": "id", "affinity": "TEXT", "notNull": false, - "default": null + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" }, { "name": "deviceId", @@ -1102,14 +1111,17 @@ ] }, "liftSession": { - "platform": "ios_only", - "reason": "The in-app strength log (v46): saved programs and the sessions run from them. Landed on Apple first so the schema can settle against real gym use before it is frozen into a Room twin; the Kotlin side is a tracked follow-up, not a divergence. Nothing here feeds a score, so a device without these tables computes identical metrics.", + "platform": "both", "columns": [ { "name": "id", "affinity": "TEXT", "notNull": false, - "default": null + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" }, { "name": "deviceId", @@ -1176,14 +1188,17 @@ ] }, "liftSet": { - "platform": "ios_only", - "reason": "The in-app strength log (v46): saved programs and the sessions run from them. Landed on Apple first so the schema can settle against real gym use before it is frozen into a Room twin; the Kotlin side is a tracked follow-up, not a divergence. Nothing here feeds a score, so a device without these tables computes identical metrics.", + "platform": "both", "columns": [ { "name": "id", "affinity": "TEXT", "notNull": false, - "default": null + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" }, { "name": "deviceId", diff --git a/android/app/src/main/java/com/noop/data/LiftEntities.kt b/android/app/src/main/java/com/noop/data/LiftEntities.kt new file mode 100644 index 0000000000..a2578e24a2 --- /dev/null +++ b/android/app/src/main/java/com/noop/data/LiftEntities.kt @@ -0,0 +1,194 @@ +package com.noop.data + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * The in-app strength log — the Room half of GRDB's `v46-lift-log`. + * + * Five `deviceId`-keyed tables holding saved programs and the sessions run from them. The Apple side + * ships the screens that read and write these; this is the SCHEMA twin, landed with the migration + * rather than after it, because `docs/CONTRIBUTING.md` is explicit that a migration cannot land + * until its twin does and that the oracle's divergence ledger should only ever shrink. + * + * WHAT THIS DELIBERATELY IS NOT. There is no DAO and no Compose UI here yet, so nothing on Android + * reads or writes these tables today. That is a stated gap, not an oversight: the value of a gym log + * book is entirely in how it feels 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. An Android user should + * take that half. What is landed here is the half that CAN be proved correct without a device — + * `SchemaOracleTest` compares Room's KSP-exported schema against the same `schema_oracle.json` the + * GRDB suite checks, so a column, order, affinity, nullability, default, key or index that drifts + * from Apple's fails this build. + * + * FIELD ORDER IS THE CONTRACT. Room emits columns in declaration order, so each class below follows + * its GRDB `create(table:)` exactly; a reordering here is a real schema divergence even though every + * column still exists (`CLAUDE.md`, and the `battery-alter-append-order` ledger entry that records + * what it costs to fix one after the fact). + * + * Two shapes worth naming: + * - `archived` and `isWarmup` carry `@ColumnInfo(defaultValue = "0")`. A Kotlin constructor default + * never reaches the schema — only the annotation does — and GRDB declares both NOT NULL DEFAULT 0. + * Leaving the annotation off would reproduce the `room-omits-sql-default` divergence on brand-new + * tables, which is the one chance there is to simply not have it. + * - Each `id` is a non-null Kotlin `String`, so Room writes `id TEXT NOT NULL, PRIMARY KEY(id)` + * while SQLite's legacy quirk leaves GRDB's `id TEXT PRIMARY KEY` nullable. That is the existing + * `sqlite-text-pk-nullable` ledger entry, which every TEXT-keyed table on both platforms already + * carries; no writer on either side ever supplies a null id. + */ + +/** + * The user's own exercise vocabulary. NOOP ships **no** exercise catalogue and no exercise→muscle + * mapping: the name is whatever the user types, and the muscles are the ones they assigned. A closed + * catalogue silently mis-attributes everything it fails to recognise. + */ +@Entity( + tableName = "liftExercise", + indices = [ + Index(name = "idx_liftExercise_natural", value = ["deviceId", "name"], unique = true), + ], +) +data class LiftExerciseRow( + @PrimaryKey + val id: String, + val deviceId: String, + val name: String, + /** A `LiftMuscle` raw value. A stored-data contract: never rename or remove a token. */ + val primaryMuscle: String? = null, + /** Comma-separated `LiftMuscle` raw values, same contract. */ + val secondaryMuscles: String? = null, + val createdAt: Long, + /** Unix seconds; recency, so the picker can offer what was used most recently. */ + val lastUsedTs: Long? = null, +) + +/** A reusable program, e.g. "Upper A". Archived rather than deleted, so history keeps resolving. */ +@Entity( + tableName = "liftProgram", + indices = [ + Index(name = "idx_liftProgram_device_updatedAt", value = ["deviceId", "updatedAt"]), + ], +) +data class LiftProgramRow( + @PrimaryKey + val id: String, + val deviceId: String, + val name: String, + val note: String? = null, + val createdAt: Long, + /** Unix seconds; drives most-recent-first ordering in the hub. */ + val updatedAt: Long, + @ColumnInfo(defaultValue = "0") + val archived: Boolean = false, +) + +/** + * One exercise line inside a program: the TARGETS. What actually happened lives in [LiftSetEntity]. + * + * A line plans a WEIGHT, not only a rep range — `targetWeightKg` — because a program that cannot say + * how heavy is not a program anyone follows. + */ +@Entity( + tableName = "liftProgramItem", + indices = [ + Index(name = "idx_liftProgramItem_device", value = ["deviceId"]), + Index(name = "idx_liftProgramItem_program_ord", value = ["programId", "ord"]), + ], +) +data class LiftProgramItemRow( + @PrimaryKey + val id: String, + val deviceId: String, + val programId: String, + /** Position within the program, 0-based. */ + val ord: Int, + val exercise: String, + val targetSets: Int? = null, + /** Rep-range low end — the 8 of "8-10". */ + val targetRepsLow: Int? = null, + val targetRepsHigh: Int? = null, + /** Target RPE on the user's own 1-10 scale. */ + val targetRpe: Double? = null, + val targetWeightKg: Double? = null, + /** Intended rest after each set, seconds. */ + val restSec: Int? = null, + /** The user's own technique cue, stored and shown back verbatim. */ + val note: String? = null, +) + +/** + * One gym session. Pairs 1:1 with a `workout` row through the natural key + * `(deviceId, startTs, sport)`, which is why that index is UNIQUE. + * + * `sessionRpe` is a NUMBER rather than text 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. + */ +@Entity( + tableName = "liftSession", + indices = [ + Index(name = "idx_liftSession_natural", value = ["deviceId", "startTs", "sport"], unique = true), + ], +) +data class LiftSessionRow( + @PrimaryKey + val id: String, + val deviceId: String, + /** Unix seconds; the same instant as the paired `workout.startTs`. */ + val startTs: Long, + /** Nil while the session is still running. */ + val endTs: Long? = null, + /** The same token as the paired `workout.sport`. */ + val sport: String, + /** Nil for a freehand session with no program behind it. */ + val programId: String? = null, + /** The program's name AS IT WAS, so a later rename never rewrites history. */ + val programName: String? = null, + val sessionRpe: Double? = null, + val note: String? = null, +) + +/** + * One set as performed — rows, not a JSON blob, because "what did I lift for this exercise last + * time" is the read the whole feature exists for, and against a blob it is not answerable by an + * index. + * + * `primaryMuscle` / `secondaryMuscles` are snapshotted AS THEY WERE at log time, so reclassifying an + * exercise later never silently rewrites what past weeks were counted as. + * + * Named `LiftSetEntity` rather than `LiftSetRow` only because `Row` is already this package's name + * for several unrelated types; the TABLE is `liftSet`, which is what the oracle compares. + */ +@Entity( + tableName = "liftSet", + indices = [ + Index(name = "idx_liftSet_device_exercise", value = ["deviceId", "exercise"]), + Index(name = "idx_liftSet_session_ord", value = ["sessionId", "ord"]), + ], +) +data class LiftSetEntity( + @PrimaryKey + val id: String, + val deviceId: String, + val sessionId: String, + /** Order within the session — COMPLETION order, which with out-of-order work is not plan order. */ + val ord: Int, + val exercise: String, + val primaryMuscle: String? = null, + val secondaryMuscles: String? = null, + /** 1-based within its exercise. */ + val setIndex: Int, + /** Kilograms. Display units convert; storage does not. */ + val weightKg: Double? = null, + val reps: Int? = null, + /** 1-10 as rated by the user, and never carried from another set. */ + val rpe: Double? = null, + /** Warm-ups are excluded from volume and from the per-muscle counts. */ + @ColumnInfo(defaultValue = "0") + val isWarmup: Boolean = false, + val startTs: Long? = null, + val endTs: Long? = null, + /** Rest ACTUALLY taken after this set, measured from the taps rather than planned. */ + val restSec: Int? = null, + val note: String? = null, +) diff --git a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt index 450d84f86a..edf3c7d2b6 100644 --- a/android/app/src/main/java/com/noop/data/WhoopDatabase.kt +++ b/android/app/src/main/java/com/noop/data/WhoopDatabase.kt @@ -53,8 +53,13 @@ import androidx.sqlite.db.SupportSQLiteDatabase V18AuxSampleEntity::class, AppleStepHour::class, CoachMessageRow::class, + LiftExerciseRow::class, + LiftProgramRow::class, + LiftProgramItemRow::class, + LiftSessionRow::class, + LiftSetEntity::class, ], - version = 39, + version = 40, // #775: ON so Room's KSP processor writes the generated schema (every table's exact `CREATE TABLE`, // columns in declaration order with affinity/NOT NULL/default, PK and indices) as JSON. That export // is what lets a plain JVM test — no device, no Robolectric — read Android's REAL schema and compare @@ -74,7 +79,7 @@ abstract class WhoopDatabase : RoomDatabase() { const val DB_NAME = "noop_whoop.db" /** Room schema version — MUST equal the `@Database(version = …)` above. Surfaced in the backup * manifest (#1410) so an export states its schema. Bump both together on a migration. */ - const val SCHEMA_VERSION = 39 + const val SCHEMA_VERSION = 40 @Volatile private var instance: WhoopDatabase? = null @@ -996,6 +1001,95 @@ abstract class WhoopDatabase : RoomDatabase() { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL(RR_SOURCE_INDEX_SQL) } } + /** + * The in-app strength log. Twin of GRDB `v46-lift-log`; see `LiftEntities.kt` for what these + * tables hold and for why the Android half is schema-only for now. + * + * Column order matches the entity declaration order, which matches GRDB's `create(table:)`, + * and `SchemaOracleTest` fails on any of the three drifting apart. Every statement is + * `IF NOT EXISTS`, matching the GRDB side: a database that already carries these tables + * converges rather than throwing. + */ + internal val LIFT_LOG_SQL: List = listOf( + """CREATE TABLE IF NOT EXISTS `liftExercise` ( + `id` TEXT NOT NULL, + `deviceId` TEXT NOT NULL, + `name` TEXT NOT NULL, + `primaryMuscle` TEXT, + `secondaryMuscles` TEXT, + `createdAt` INTEGER NOT NULL, + `lastUsedTs` INTEGER, + PRIMARY KEY(`id`) + )""", + "CREATE UNIQUE INDEX IF NOT EXISTS `idx_liftExercise_natural` ON `liftExercise` (`deviceId`, `name`)", + """CREATE TABLE IF NOT EXISTS `liftProgram` ( + `id` TEXT NOT NULL, + `deviceId` TEXT NOT NULL, + `name` TEXT NOT NULL, + `note` TEXT, + `createdAt` INTEGER NOT NULL, + `updatedAt` INTEGER NOT NULL, + `archived` INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(`id`) + )""", + "CREATE INDEX IF NOT EXISTS `idx_liftProgram_device_updatedAt` ON `liftProgram` (`deviceId`, `updatedAt`)", + """CREATE TABLE IF NOT EXISTS `liftProgramItem` ( + `id` TEXT NOT NULL, + `deviceId` TEXT NOT NULL, + `programId` TEXT NOT NULL, + `ord` INTEGER NOT NULL, + `exercise` TEXT NOT NULL, + `targetSets` INTEGER, + `targetRepsLow` INTEGER, + `targetRepsHigh` INTEGER, + `targetRpe` REAL, + `targetWeightKg` REAL, + `restSec` INTEGER, + `note` TEXT, + PRIMARY KEY(`id`) + )""", + "CREATE INDEX IF NOT EXISTS `idx_liftProgramItem_device` ON `liftProgramItem` (`deviceId`)", + "CREATE INDEX IF NOT EXISTS `idx_liftProgramItem_program_ord` ON `liftProgramItem` (`programId`, `ord`)", + """CREATE TABLE IF NOT EXISTS `liftSession` ( + `id` TEXT NOT NULL, + `deviceId` TEXT NOT NULL, + `startTs` INTEGER NOT NULL, + `endTs` INTEGER, + `sport` TEXT NOT NULL, + `programId` TEXT, + `programName` TEXT, + `sessionRpe` REAL, + `note` TEXT, + PRIMARY KEY(`id`) + )""", + "CREATE UNIQUE INDEX IF NOT EXISTS `idx_liftSession_natural` ON `liftSession` (`deviceId`, `startTs`, `sport`)", + """CREATE TABLE IF NOT EXISTS `liftSet` ( + `id` TEXT NOT NULL, + `deviceId` TEXT NOT NULL, + `sessionId` TEXT NOT NULL, + `ord` INTEGER NOT NULL, + `exercise` TEXT NOT NULL, + `primaryMuscle` TEXT, + `secondaryMuscles` TEXT, + `setIndex` INTEGER NOT NULL, + `weightKg` REAL, + `reps` INTEGER, + `rpe` REAL, + `isWarmup` INTEGER NOT NULL DEFAULT 0, + `startTs` INTEGER, + `endTs` INTEGER, + `restSec` INTEGER, + `note` TEXT, + PRIMARY KEY(`id`) + )""", + "CREATE INDEX IF NOT EXISTS `idx_liftSet_device_exercise` ON `liftSet` (`deviceId`, `exercise`)", + "CREATE INDEX IF NOT EXISTS `idx_liftSet_session_ord` ON `liftSet` (`sessionId`, `ord`)", + ) + + internal val MIGRATION_39_40 = object : Migration(39, 40) { + override fun migrate(db: SupportSQLiteDatabase) { LIFT_LOG_SQL.forEach(db::execSQL) } + } + /** * Every migration the builder registers, as a VALUE rather than an argument list. * @@ -1022,7 +1116,7 @@ abstract class WhoopDatabase : RoomDatabase() { MIGRATION_22_23, MIGRATION_23_24, MIGRATION_24_25, MIGRATION_25_26, MIGRATION_26_27, MIGRATION_27_28, MIGRATION_28_29, MIGRATION_29_30, MIGRATION_30_31, MIGRATION_31_32, MIGRATION_32_33, MIGRATION_33_34, MIGRATION_34_35, MIGRATION_35_36, - MIGRATION_36_37, MIGRATION_37_38, MIGRATION_38_39, + MIGRATION_36_37, MIGRATION_37_38, MIGRATION_38_39, MIGRATION_39_40, ) private fun build(appContext: Context): WhoopDatabase = diff --git a/android/app/src/test/resources/schema_oracle.json b/android/app/src/test/resources/schema_oracle.json index 88cfe26de8..4ab143d2b4 100644 --- a/android/app/src/test/resources/schema_oracle.json +++ b/android/app/src/test/resources/schema_oracle.json @@ -1,6 +1,6 @@ { "_readme": "SHARED Room<->GRDB SCHEMA ORACLE (#775). Two byte-identical copies: Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json and android/app/src/test/resources/schema_oracle.json. SchemaOracleTests.swift compares GRDB's PRAGMA table_info/index_list against it; SchemaOracleTest.kt compares Room's exported schema JSON against it. `columns` is the iOS/GRDB shape in GRDB column order (macOS is the reference implementation); every way Android differs is spelled out in an `android` / `iosAbsent` / `androidColumnOrder` override naming a key in `divergenceReasons`. Adding a column, reordering one, or changing a type/nullability on one platform only fails both suites until it is either fixed or written down here.", - "roomVersion": 39, + "roomVersion": 40, "grdbMigrations": [ "v1", "v2", @@ -882,14 +882,17 @@ ] }, "liftExercise": { - "platform": "ios_only", - "reason": "The in-app strength log (v46): saved programs and the sessions run from them. Landed on Apple first so the schema can settle against real gym use before it is frozen into a Room twin; the Kotlin side is a tracked follow-up, not a divergence. Nothing here feeds a score, so a device without these tables computes identical metrics.", + "platform": "both", "columns": [ { "name": "id", "affinity": "TEXT", "notNull": false, - "default": null + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" }, { "name": "deviceId", @@ -943,14 +946,17 @@ ] }, "liftProgram": { - "platform": "ios_only", - "reason": "The in-app strength log (v46): saved programs and the sessions run from them. Landed on Apple first so the schema can settle against real gym use before it is frozen into a Room twin; the Kotlin side is a tracked follow-up, not a divergence. Nothing here feeds a score, so a device without these tables computes identical metrics.", + "platform": "both", "columns": [ { "name": "id", "affinity": "TEXT", "notNull": false, - "default": null + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" }, { "name": "deviceId", @@ -1004,14 +1010,17 @@ ] }, "liftProgramItem": { - "platform": "ios_only", - "reason": "The in-app strength log (v46): saved programs and the sessions run from them. Landed on Apple first so the schema can settle against real gym use before it is frozen into a Room twin; the Kotlin side is a tracked follow-up, not a divergence. Nothing here feeds a score, so a device without these tables computes identical metrics.", + "platform": "both", "columns": [ { "name": "id", "affinity": "TEXT", "notNull": false, - "default": null + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" }, { "name": "deviceId", @@ -1102,14 +1111,17 @@ ] }, "liftSession": { - "platform": "ios_only", - "reason": "The in-app strength log (v46): saved programs and the sessions run from them. Landed on Apple first so the schema can settle against real gym use before it is frozen into a Room twin; the Kotlin side is a tracked follow-up, not a divergence. Nothing here feeds a score, so a device without these tables computes identical metrics.", + "platform": "both", "columns": [ { "name": "id", "affinity": "TEXT", "notNull": false, - "default": null + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" }, { "name": "deviceId", @@ -1176,14 +1188,17 @@ ] }, "liftSet": { - "platform": "ios_only", - "reason": "The in-app strength log (v46): saved programs and the sessions run from them. Landed on Apple first so the schema can settle against real gym use before it is frozen into a Room twin; the Kotlin side is a tracked follow-up, not a divergence. Nothing here feeds a score, so a device without these tables computes identical metrics.", + "platform": "both", "columns": [ { "name": "id", "affinity": "TEXT", "notNull": false, - "default": null + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" }, { "name": "deviceId", From 203b2b829c6e7791b2ad8e656d64eb01449da52a Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:31:56 +0200 Subject: [PATCH 3/4] store: clear the lift tables on Android's delete-all and adopt-serial re-key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review catch on #2098: `DeviceRegistryStore.deviceScopedTables` gained all five tables on the Swift side and `DeviceRegistryDao` — its Android twin — did not, so the two lists disagreed. The #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 --- .../main/java/com/noop/data/DeviceRegistry.kt | 10 ++++++++++ .../main/java/com/noop/data/DeviceRegistryDao.kt | 16 ++++++++++++++++ .../noop/analytics/RegistryDayOwnerSourceTest.kt | 10 ++++++++++ .../noop/ble/SourceCoordinatorAdoptionTest.kt | 10 ++++++++++ .../java/com/noop/data/DeviceRegistryTest.kt | 14 ++++++++++++++ 5 files changed, 60 insertions(+) diff --git a/android/app/src/main/java/com/noop/data/DeviceRegistry.kt b/android/app/src/main/java/com/noop/data/DeviceRegistry.kt index f3e8e9bf34..0961d04399 100644 --- a/android/app/src/main/java/com/noop/data/DeviceRegistry.kt +++ b/android/app/src/main/java/com/noop/data/DeviceRegistry.kt @@ -142,6 +142,11 @@ class DeviceRegistry( dao.reKeyLiveSessions(from, to); dao.deleteLiveSessionsFor(from) dao.reKeyDismissedWorkouts(from, to); dao.deleteDismissedWorkoutsFor(from) dao.reKeyDismissedSleeps(from, to); dao.deleteDismissedSleepsFor(from) + dao.reKeyLiftExercises(from, to); dao.deleteLiftExercisesFor(from) + dao.reKeyLiftPrograms(from, to); dao.deleteLiftProgramsFor(from) + dao.reKeyLiftProgramItems(from, to); dao.deleteLiftProgramItemsFor(from) + dao.reKeyLiftSessions(from, to); dao.deleteLiftSessionsFor(from) + dao.reKeyLiftSets(from, to); dao.deleteLiftSetsFor(from) } /** Archive a device — keeps its row and samples (invariant I4). */ @@ -223,6 +228,11 @@ class DeviceRegistry( dao.deleteLiveSessionsFor(id) dao.deleteDismissedWorkoutsFor(id) dao.deleteDismissedSleepsFor(id) + dao.deleteLiftExercisesFor(id) + dao.deleteLiftProgramsFor(id) + dao.deleteLiftProgramItemsFor(id) + dao.deleteLiftSessionsFor(id) + dao.deleteLiftSetsFor(id) } } diff --git a/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt b/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt index 099edec1b6..78382d913d 100644 --- a/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt +++ b/android/app/src/main/java/com/noop/data/DeviceRegistryDao.kt @@ -111,6 +111,17 @@ interface DeviceRegistryDao { @Query("DELETE FROM liveSession WHERE deviceId = :deviceId") suspend fun deleteLiveSessionsFor(deviceId: String) @Query("DELETE FROM dismissedWorkout WHERE deviceId = :deviceId") suspend fun deleteDismissedWorkoutsFor(deviceId: String) @Query("DELETE FROM dismissedSleep WHERE deviceId = :deviceId") suspend fun deleteDismissedSleepsFor(deviceId: String) + // v46-lift-log: the five strength-log tables, including the CHILD rows. `liftProgramItem` and + // `liftSet` join to their parents by id rather than by a foreign key, so deleting only the parents + // would strip the programs and sessions and leave every logged set behind — the same shape as the + // audit finding above. Landed WITH the schema rather than after it: no Android DAO writes these + // tables yet, so nothing would notice the gap until something did, and `deleteDeviceDataCallsEvery\ + // DaoDeleteMethod` cannot see a table that has no method at all. + @Query("DELETE FROM liftExercise WHERE deviceId = :deviceId") suspend fun deleteLiftExercisesFor(deviceId: String) + @Query("DELETE FROM liftProgram WHERE deviceId = :deviceId") suspend fun deleteLiftProgramsFor(deviceId: String) + @Query("DELETE FROM liftProgramItem WHERE deviceId = :deviceId") suspend fun deleteLiftProgramItemsFor(deviceId: String) + @Query("DELETE FROM liftSession WHERE deviceId = :deviceId") suspend fun deleteLiftSessionsFor(deviceId: String) + @Query("DELETE FROM liftSet WHERE deviceId = :deviceId") suspend fun deleteLiftSetsFor(deviceId: String) // #771 adopt-serial: re-key one device's rows onto the serial id across every device-scoped table. // `UPDATE OR IGNORE` so the canonical (serial) row wins any (deviceId, ts…) primary-key clash; the @@ -141,6 +152,11 @@ interface DeviceRegistryDao { @Query("UPDATE OR IGNORE liveSession SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyLiveSessions(from: String, to: String) @Query("UPDATE OR IGNORE dismissedWorkout SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyDismissedWorkouts(from: String, to: String) @Query("UPDATE OR IGNORE dismissedSleep SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyDismissedSleeps(from: String, to: String) + @Query("UPDATE OR IGNORE liftExercise SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyLiftExercises(from: String, to: String) + @Query("UPDATE OR IGNORE liftProgram SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyLiftPrograms(from: String, to: String) + @Query("UPDATE OR IGNORE liftProgramItem SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyLiftProgramItems(from: String, to: String) + @Query("UPDATE OR IGNORE liftSession SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyLiftSessions(from: String, to: String) + @Query("UPDATE OR IGNORE liftSet SET deviceId = :to WHERE deviceId = :from") suspend fun reKeyLiftSets(from: String, to: String) /** The registry row for [id], or null. (#771 adopt-serial needs the active row's fields to clone/carry.) */ @Query("SELECT * FROM pairedDevice WHERE id = :id") diff --git a/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt b/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt index 7ec2695e49..bcd86b6990 100644 --- a/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt +++ b/android/app/src/test/java/com/noop/analytics/RegistryDayOwnerSourceTest.kt @@ -83,6 +83,11 @@ class RegistryDayOwnerSourceTest { override suspend fun deleteLiveSessionsFor(deviceId: String) {} override suspend fun deleteDismissedWorkoutsFor(deviceId: String) {} override suspend fun deleteDismissedSleepsFor(deviceId: String) {} + override suspend fun deleteLiftExercisesFor(deviceId: String) {} + override suspend fun deleteLiftProgramsFor(deviceId: String) {} + override suspend fun deleteLiftProgramItemsFor(deviceId: String) {} + override suspend fun deleteLiftSessionsFor(deviceId: String) {} + override suspend fun deleteLiftSetsFor(deviceId: String) {} // #771 adopt-serial re-key: sample-table re-keys are unmodelled here (no per-table storage in // this fake), same as the delete*For no-ops above. dayOwnership IS modelled ([owners]), so its @@ -114,6 +119,11 @@ class RegistryDayOwnerSourceTest { override suspend fun reKeyLiveSessions(from: String, to: String) {} override suspend fun reKeyDismissedWorkouts(from: String, to: String) {} override suspend fun reKeyDismissedSleeps(from: String, to: String) {} + override suspend fun reKeyLiftExercises(from: String, to: String) {} + override suspend fun reKeyLiftPrograms(from: String, to: String) {} + override suspend fun reKeyLiftProgramItems(from: String, to: String) {} + override suspend fun reKeyLiftSessions(from: String, to: String) {} + override suspend fun reKeyLiftSets(from: String, to: String) {} override suspend fun pairedDevice(id: String): PairedDeviceRow? = devices[id] diff --git a/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt b/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt index 573cd4006b..f252803495 100644 --- a/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt +++ b/android/app/src/test/java/com/noop/ble/SourceCoordinatorAdoptionTest.kt @@ -96,6 +96,11 @@ class SourceCoordinatorAdoptionTest { override suspend fun deleteLiveSessionsFor(deviceId: String) {} override suspend fun deleteDismissedWorkoutsFor(deviceId: String) {} override suspend fun deleteDismissedSleepsFor(deviceId: String) {} + override suspend fun deleteLiftExercisesFor(deviceId: String) {} + override suspend fun deleteLiftProgramsFor(deviceId: String) {} + override suspend fun deleteLiftProgramItemsFor(deviceId: String) {} + override suspend fun deleteLiftSessionsFor(deviceId: String) {} + override suspend fun deleteLiftSetsFor(deviceId: String) {} override suspend fun deleteDayOwnershipFor(deviceId: String) { owners.entries.removeIf { it.value.deviceId == deviceId } } @@ -131,6 +136,11 @@ class SourceCoordinatorAdoptionTest { override suspend fun reKeyLiveSessions(from: String, to: String) {} override suspend fun reKeyDismissedWorkouts(from: String, to: String) {} override suspend fun reKeyDismissedSleeps(from: String, to: String) {} + override suspend fun reKeyLiftExercises(from: String, to: String) {} + override suspend fun reKeyLiftPrograms(from: String, to: String) {} + override suspend fun reKeyLiftProgramItems(from: String, to: String) {} + override suspend fun reKeyLiftSessions(from: String, to: String) {} + override suspend fun reKeyLiftSets(from: String, to: String) {} override suspend fun pairedDevice(id: String): PairedDeviceRow? = devices[id] diff --git a/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt b/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt index 6c747b8520..7af6ae2af9 100644 --- a/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt +++ b/android/app/src/test/java/com/noop/data/DeviceRegistryTest.kt @@ -119,6 +119,11 @@ class DeviceRegistryTest { override suspend fun deleteLiveSessionsFor(deviceId: String) { deletedTables += "liveSession" to deviceId } override suspend fun deleteDismissedWorkoutsFor(deviceId: String) { deletedTables += "dismissedWorkout" to deviceId } override suspend fun deleteDismissedSleepsFor(deviceId: String) { deletedTables += "dismissedSleep" to deviceId } + override suspend fun deleteLiftExercisesFor(deviceId: String) { deletedTables += "liftExercise" to deviceId } + override suspend fun deleteLiftProgramsFor(deviceId: String) { deletedTables += "liftProgram" to deviceId } + override suspend fun deleteLiftProgramItemsFor(deviceId: String) { deletedTables += "liftProgramItem" to deviceId } + override suspend fun deleteLiftSessionsFor(deviceId: String) { deletedTables += "liftSession" to deviceId } + override suspend fun deleteLiftSetsFor(deviceId: String) { deletedTables += "liftSet" to deviceId } // #771 adopt-serial re-key: sample-table re-keys are unmodelled here (no per-table storage in // this fake), same as the delete*For no-ops above for those tables. dayOwnership IS modelled @@ -151,6 +156,11 @@ class DeviceRegistryTest { override suspend fun reKeyLiveSessions(from: String, to: String) {} override suspend fun reKeyDismissedWorkouts(from: String, to: String) {} override suspend fun reKeyDismissedSleeps(from: String, to: String) {} + override suspend fun reKeyLiftExercises(from: String, to: String) {} + override suspend fun reKeyLiftPrograms(from: String, to: String) {} + override suspend fun reKeyLiftProgramItems(from: String, to: String) {} + override suspend fun reKeyLiftSessions(from: String, to: String) {} + override suspend fun reKeyLiftSets(from: String, to: String) {} /** The registry row for [id], or null (#771 adopt-serial needs the active row's fields). */ override suspend fun pairedDevice(id: String): PairedDeviceRow? = devices[id] @@ -319,6 +329,10 @@ class DeviceRegistryTest { // `.noopbak` restored from iOS carries its rows — and THIS path is "Remove Apple Health // imported data", so leaving them behind would be the plainest form of the defect. "appleStepHour", + // v46-lift-log: the strength log, CHILD rows included. liftProgramItem and liftSet join to + // their parents by id rather than by a foreign key, so clearing only the parents would leave + // every logged set behind. + "liftExercise", "liftProgram", "liftProgramItem", "liftSession", "liftSet", ) assertEquals(expectedTables, dao.deletedTables.map { it.first }.toSet()) // Every delete was scoped to the requested device, not the seeded my-whoop. From eb51c979fe120485809f0d9cf4257d962f3520e7 Mon Sep 17 00:00:00 2001 From: Utku Deniz Altiok <93100191+UtkuDenizAltiok@users.noreply.github.com> Date: Sun, 13 Sep 2026 10:38:29 +0200 Subject: [PATCH 4/4] store: drop the uncalled natural-key session lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .../WhoopStore/Sources/WhoopStore/LiftLogStore.swift | 11 ----------- .../Tests/WhoopStoreTests/LiftLogStoreTests.swift | 10 ---------- 2 files changed, 21 deletions(-) diff --git a/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift index 6a1911d1df..b618be49d4 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift @@ -514,17 +514,6 @@ extension WhoopStore { } } - /// The session paired with a workout row, by that row's natural key. Nil when the workout was - /// not logged through the lift log. - public func liftSession(deviceId: String, startTs: Int, sport: String) async throws -> LiftSessionRow? { - try syncRead { db in - try Row.fetchOne(db, sql: """ - SELECT * FROM liftSession - WHERE deviceId = ? AND startTs = ? AND sport = ? - """, arguments: [deviceId, startTs, sport]).map(LiftSessionRow.decode) - } - } - /// Delete a session and every set in it. Returns true if a session row was removed. @discardableResult public func deleteLiftSession(id: String) async throws -> Bool { diff --git a/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift b/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift index 086a46a42d..f4c88a3047 100644 --- a/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift @@ -235,16 +235,6 @@ final class LiftLogStoreTests: XCTestCase { XCTAssertEqual(sessions.first?.id, "s1", "the original id is kept") } - func testSessionLookupByWorkoutNaturalKey() async throws { - let store = try await WhoopStore.inMemory() - _ = try await store.upsertLiftSessions([mkSession(id: "s1", startTs: 1_000)]) - - let hit = try await store.liftSession(deviceId: dev, startTs: 1_000, sport: sport) - XCTAssertEqual(hit?.id, "s1") - let miss = try await store.liftSession(deviceId: dev, startTs: 2_000, sport: sport) - XCTAssertNil(miss, "a workout not logged through the lift log has no session") - } - func testDeletingASessionDeletesItsSets() async throws { let store = try await WhoopStore.inMemory() _ = try await store.upsertLiftSessions([mkSession(id: "s1", startTs: 1_000)])