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..b618be49d4 --- /dev/null +++ b/Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift @@ -0,0 +1,701 @@ +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) + } + } + + /// 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..f4c88a3047 --- /dev/null +++ b/Packages/WhoopStore/Tests/WhoopStoreTests/LiftLogStoreTests.swift @@ -0,0 +1,516 @@ +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 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..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", @@ -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,438 @@ } ] }, + "liftExercise": { + "platform": "both", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" + }, + { + "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": "both", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" + }, + { + "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": "both", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" + }, + { + "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": "both", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" + }, + { + "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": "both", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" + }, + { + "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/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/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/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. diff --git a/android/app/src/test/resources/schema_oracle.json b/android/app/src/test/resources/schema_oracle.json index f48c4f1145..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", @@ -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,438 @@ } ] }, + "liftExercise": { + "platform": "both", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" + }, + { + "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": "both", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" + }, + { + "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": "both", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" + }, + { + "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": "both", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" + }, + { + "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": "both", + "columns": [ + { + "name": "id", + "affinity": "TEXT", + "notNull": false, + "default": null, + "android": { + "notNull": true + }, + "divergence": "sqlite-text-pk-nullable" + }, + { + "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": [