Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
69c8abf
lift log: one Save, zeros for discarded sets, and add or remove sets …
UtkuDenizAltiok Sep 15, 2026
7e5beed
analytics(android): a set with zero reps counts nowhere in the Kotlin…
UtkuDenizAltiok Sep 15, 2026
fcba4cc
lift log: the Kotlin twin of deleting individual sets
UtkuDenizAltiok Sep 16, 2026
a706f32
lift log: a max RPE per exercise, shown grey in the session and read …
UtkuDenizAltiok Sep 15, 2026
0c5a614
lift log: a set left unrated saves the line's max RPE
UtkuDenizAltiok Sep 15, 2026
cad6902
lift log: one tap moves the cursor from one field to the next
UtkuDenizAltiok Sep 16, 2026
b687051
lift log: hold back a strap knock, and buzz ahead of the sync the tap…
UtkuDenizAltiok Sep 16, 2026
9382adb
lift log: the next set on the bar and Lock Screen, a rest clock that …
UtkuDenizAltiok Sep 16, 2026
9817283
lift log: light a locked screen only, in the one update the strap ste…
UtkuDenizAltiok Sep 16, 2026
d16000a
lift log: resolve the session bar once per render, and drop the progr…
UtkuDenizAltiok Sep 16, 2026
22a8942
lift log: a strap knock is a second tap under 5 s, not 8
UtkuDenizAltiok Sep 21, 2026
3e20db4
lift log: a set that was done is complete, and the program takes each…
UtkuDenizAltiok Sep 21, 2026
b00e4b5
lift log: light the Lock Screen whenever NOOP is not on screen, and l…
UtkuDenizAltiok Sep 21, 2026
2d5f542
lift log: one banner during a gym session — a foreground sync starts …
UtkuDenizAltiok Sep 21, 2026
d90e68d
lift log: Lock Screen banner — icon and numbers nearer the edges, hea…
UtkuDenizAltiok Sep 21, 2026
5efeeae
lift log: after iOS restarts NOOP, keep the Lock Screen banner instea…
UtkuDenizAltiok Sep 21, 2026
bdc7709
lift log: minimised bar laid out like the Lock Screen banner
UtkuDenizAltiok Sep 21, 2026
2fceb8a
lift log: add an exercise during a session
UtkuDenizAltiok Sep 21, 2026
ce04394
lift log: running clocks read like the Lock Screen's
UtkuDenizAltiok Sep 21, 2026
2eea5e0
lift log: a running session does no work between taps
UtkuDenizAltiok Sep 21, 2026
4966e33
lift log: the Dynamic Island shows the heart rate, and its clock keep…
UtkuDenizAltiok Sep 22, 2026
3764aca
lift log: a heart rate moves the Lock Screen banner rarely, not every…
UtkuDenizAltiok Sep 22, 2026
839616a
lift log: its strap-log lines carry their own time
UtkuDenizAltiok Sep 22, 2026
8b05e0b
parity: re-derive the twin-map authority after rebasing onto 751fa1d8
UtkuDenizAltiok Sep 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@ import WhoopStore

public enum LiftMetrics {

// MARK: - Performed or not

/// Whether a set was performed, from its rep count. A set with ZERO reps was not: that is how a
/// finished session keeps the sets the user discards, so Edit sets can still fill them in, and it is
/// what a user types for a planned set they skipped. Every figure leaves such a set out, since
/// counting it would add a set nobody did. A set with no rep count (nil) still counts: it was done,
/// the number just was not typed. `WhoopStore.liftSetCounts` and `lastLiftSets` apply the same rule
/// in SQL. One function per platform, so the parity ledger pairs the twins unambiguously.
///
/// The Kotlin twin is `LiftMetrics.isPerformed`.
public static func isPerformed(reps: Int?) -> Bool {
reps != 0
}

// MARK: - Volume load (tonnage)

/// Σ (weight × reps) over WORKING sets, in kilograms. Nil when nothing countable was logged.
Expand Down Expand Up @@ -113,13 +127,14 @@ public enum LiftMetrics {
}

/// One summary per exercise, in the order the exercises were first performed — which is the
/// order they were done in, not alphabetical, because that is how a session reads back.
/// order they were done in, not alphabetical, because that is how a session reads back. A set that
/// was not performed (`isPerformed`) appears nowhere, and neither does an exercise with only such sets.
///
/// The Kotlin twin is `LiftMetrics.perExercise`.
public static func perExercise(_ sets: [LiftSetRow]) -> [ExerciseSummary] {
var order: [String] = []
var grouped: [String: [LiftSetRow]] = [:]
for s in sets.sorted(by: { $0.ord < $1.ord }) {
for s in sets.sorted(by: { $0.ord < $1.ord }) where isPerformed(reps: s.reps) {
if grouped[s.exercise] == nil { order.append(s.exercise) }
grouped[s.exercise, default: []].append(s)
}
Expand Down Expand Up @@ -182,7 +197,7 @@ public enum LiftMetrics {
/// The Kotlin twin is `LiftMetrics.rpeProfile`.
public static func rpeProfile(_ sets: [LiftSetRow],
threshold: Double = hardSetRpeThreshold) -> RpeProfile {
let working = sets.filter { !$0.isWarmup }
let working = sets.filter { !$0.isWarmup && isPerformed(reps: $0.reps) }
let rated = working.compactMap(\.rpe)
let mean = rated.isEmpty ? nil : rated.reduce(0, +) / Double(rated.count)
return RpeProfile(mean: mean,
Expand Down Expand Up @@ -217,15 +232,16 @@ public enum LiftMetrics {
/// operationalisation, so the credit and the doses have to move together or the comparison
/// silently stops meaning anything.
///
/// Warm-ups are excluded; nothing else is. An unclassified exercise (nil primary) contributes to
/// volume and session load but claims no muscle it was never assigned.
/// Warm-ups and sets never performed (`isPerformed`) are excluded; nothing else is. An unclassified
/// exercise (nil primary) contributes to volume and session load but claims no muscle it was never
/// assigned.
///
/// The Kotlin twin is `LiftMetrics.muscleCounts`.
public static func muscleCounts(_ sets: [LiftSetRow]) -> MuscleCounts {
var fractional: [LiftMuscle: Double] = [:]
var direct: [LiftMuscle: Int] = [:]
var indirect: [LiftMuscle: Int] = [:]
for s in sets where !s.isWarmup {
for s in sets where !s.isWarmup && isPerformed(reps: s.reps) {
if let p = s.primaryMuscle {
direct[p, default: 0] += 1
fractional[p, default: 0] += LiftMuscle.directSetCredit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ final class LiftMetricsStoreAgreementTests: XCTestCase {

/// Insert sets through raw SQL so the row shapes are exactly what is asked for, including the
/// one the public API would clean up.
private func store(_ rows: [(primary: String?, secondary: String, warmup: Int)]) async throws -> WhoopStore {
private func store(_ rows: [(primary: String?, secondary: String, warmup: Int)],
reps: [Int] = []) async throws -> WhoopStore {
let store = try await WhoopStore.inMemory()
let writer = store.registryWriter
try await writer.write { db in
Expand All @@ -40,8 +41,9 @@ final class LiftMetricsStoreAgreementTests: XCTestCase {
INSERT INTO liftSet (id, deviceId, sessionId, ord, exercise, primaryMuscle,
secondaryMuscles, setIndex, weightKg, reps, rpe, isWarmup,
startTs, endTs, restSec, note)
VALUES (?, 'dev', 's1', ?, 'Exercise', ?, ?, 1, 60, 10, NULL, ?, NULL, NULL, NULL, NULL)
""", arguments: ["set-\(i)", i, r.primary, r.secondary, r.warmup])
VALUES (?, 'dev', 's1', ?, 'Exercise', ?, ?, 1, 60, ?, NULL, ?, NULL, NULL, NULL, NULL)
""", arguments: ["set-\(i)", i, r.primary, r.secondary,
reps.indices.contains(i) ? reps[i] : 10, r.warmup])
}
}
return store
Expand Down Expand Up @@ -115,4 +117,20 @@ final class LiftMetricsStoreAgreementTests: XCTestCase {
XCTAssertEqual(counts.indirect[.triceps], 1, "the recognisable half still counts")
XCTAssertTrue(counts.direct.isEmpty)
}

/// A set saved at zero reps was never performed, and neither implementation counts it. The rule is
/// exactly `reps != 0` on both sides, so a nonsensical negative count still counts in both rather
/// than in one: an SQL `reps > 0` would drop it where `LiftMetrics.isPerformed` keeps it.
func testBothImplementationsAgreeThatASetWithZeroRepsDoesNotCount() async throws {
let store = try await store([
(primary: "chest", secondary: "triceps", warmup: 0),
(primary: "chest", secondary: "triceps", warmup: 0),
(primary: "chest", secondary: "triceps", warmup: 0),
], reps: [0, 10, -1])
try await assertAgree(store)

let counts = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1)
XCTAssertEqual(counts.direct[.chest], 2, "the zero-rep set is the only one left out")
XCTAssertEqual(counts.indirect[.triceps], 2)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -193,4 +193,26 @@ final class LiftMetricsTests: XCTestCase {
XCTAssertEqual(LiftMetrics.ReferenceDose.strengthMinimumSetsPerWeek, 1.0)
XCTAssertEqual(LiftMetrics.ReferenceDose.strengthPlateauSetsPerWeek, 4.0)
}

// MARK: - Sets never performed

/// A set saved at zero reps was discarded or skipped, and counts nowhere: not as a working set, not
/// in the RPE profile, not toward a muscle. An exercise with only such sets does not appear.
func testASetWithZeroRepsCountsNowhere() {
let sets = [set(weight: 100, reps: 5, rpe: 8, primary: .chest),
set(weight: 0, reps: 0, primary: .chest),
set("Row", weight: 0, reps: 0, primary: .lats)]
XCTAssertEqual(LiftMetrics.perExercise(sets).map(\.exercise), ["Bench press"])
XCTAssertEqual(LiftMetrics.perExercise(sets).first?.workingSets, 1)
XCTAssertEqual(LiftMetrics.rpeProfile(sets).unratedSets, 0, "a discarded set is not an unrated one")
XCTAssertEqual(LiftMetrics.muscleCounts(sets).direct[.chest], 1)
XCTAssertNil(LiftMetrics.muscleCounts(sets).direct[.lats])
}

/// No rep count at all is different: the set was done, the number just was not typed.
func testASetWithNoRepCountStillCounts() {
let sets = [set(weight: nil, reps: nil, primary: .chest)]
XCTAssertTrue(LiftMetrics.isPerformed(reps: sets[0].reps))
XCTAssertEqual(LiftMetrics.muscleCounts(sets).direct[.chest], 1)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import ZIPFoundation
// Build a Lift Log PROGRAM from a spreadsheet filled in on a computer.
//
// WHY THIS EXISTS. A program is a name plus an ordered list of exercise lines, and every line carries
// an exercise, a muscle classification, sets, reps, a weight, a rest period and a technique note.
// an exercise, a muscle classification, sets, reps, a weight, a max RPE, a rest period and a technique note.
// Typing all of that on a phone, for a dozen exercises, is the single most tedious thing in the
// feature — and it is exactly the kind of work a spreadsheet on a real keyboard is good at.
//
Expand All @@ -27,18 +27,21 @@ public struct ImportedProgramLine: Sendable, Equatable {
public var targetSets: Int?
public var targetReps: Int?
public var targetWeightKg: Double?
/// The line's max RPE, 1-10: a ceiling to stay under, stored as `liftProgramItem.targetRpe`.
public var targetMaxRpe: Double?
public var restSec: Int?
public var note: String?

public init(exercise: String, primaryMuscle: LiftMuscle?, secondaryMuscles: [LiftMuscle],
targetSets: Int?, targetReps: Int?, targetWeightKg: Double?,
targetSets: Int?, targetReps: Int?, targetWeightKg: Double?, targetMaxRpe: Double?,
restSec: Int?, note: String?) {
self.exercise = exercise
self.primaryMuscle = primaryMuscle
self.secondaryMuscles = secondaryMuscles
self.targetSets = targetSets
self.targetReps = targetReps
self.targetWeightKg = targetWeightKg
self.targetMaxRpe = targetMaxRpe
self.restSec = restSec
self.note = note
}
Expand Down Expand Up @@ -196,6 +199,17 @@ public enum LiftProgramSheetImporter {
}
}

// A max RPE outside the scale is refused with a warning rather than clamped: 12 is a typo,
// and guessing whether it meant 10 or 1.2 would put a ceiling in the plan nobody chose.
var maxRpe = doubleValue(row, ["target_max_rpe", "max_rpe", "rpe_max", "target_rpe", "rpe"])
if let rpe = maxRpe, !(1...10).contains(rpe) {
maxRpe = nil
if warnings.count < maxWarnings {
let shown = rpe == rpe.rounded() ? String(Int(rpe)) : String(rpe)
warnings.append(rowMessage(i, "max RPE \(shown) is not between 1 and 10, so \"\(exercise)\" has none"))
}
}

// Notes are capped to the same lengths the in-app editors enforce. A spreadsheet cell
// holds far more than a phone can show, and a note that arrives longer than the editor
// would ever let you type is a note you can never fully see or edit afterwards.
Expand All @@ -206,6 +220,7 @@ public enum LiftProgramSheetImporter {
targetSets: intValue(row, setsKeys),
targetReps: intValue(row, repsKeys),
targetWeightKg: doubleValue(row, weightKeys),
targetMaxRpe: maxRpe,
restSec: intValue(row, restKeys),
note: value(row, noteKeys)?.trimmed.nilIfEmpty
.map { String($0.prefix(WhoopStore.maxExerciseNoteLength)) })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,31 @@ final class LiftProgramSheetImporterTests: XCTestCase {
XCTAssertEqual(r.programs[0].lines[2].targetWeightKg, 40.5)
}

/// Max RPE is a ceiling on the 1-10 scale. The template's header, a decimal comma and the shorter
/// spellings a hand-built sheet uses all read; a blank stays nil.
func testMaxRpeIsReadFromTheTemplateHeaderAndItsShorterSpellings() throws {
let csv = "Exercise,Target max RPE\nBack squat,\"8,5\"\nBench press,\n"
let r = try LiftProgramSheetImporter.parse(data: Data(csv.utf8))
XCTAssertEqual(r.programs[0].lines[0].targetMaxRpe, 8.5)
XCTAssertNil(r.programs[0].lines[1].targetMaxRpe, "a blank cell means no ceiling")
for header in ["Max RPE", "RPE"] {
let short = try LiftProgramSheetImporter.parse(data: Data("Exercise,\(header)\nRow,7\n".utf8))
XCTAssertEqual(short.programs[0].lines[0].targetMaxRpe, 7, "failed on \(header)")
}
}

/// Outside 1-10 is a typo, not a ceiling: the line imports without one and the row is named.
func testAMaxRpeOutsideTheScaleWarnsAndIsLeftBlank() throws {
let csv = "Exercise,Target max RPE\nBack squat,12\nBench press,0\n"
let r = try LiftProgramSheetImporter.parse(data: Data(csv.utf8))
XCTAssertEqual(r.programs[0].lines.map(\.exercise), ["Back squat", "Bench press"])
XCTAssertNil(r.programs[0].lines[0].targetMaxRpe)
XCTAssertNil(r.programs[0].lines[1].targetMaxRpe)
XCTAssertTrue(r.warnings.contains { $0.hasPrefix("Row 2:") && $0.contains("12") && $0.contains("Back squat") },
"the warning names the row, the value and the exercise: \(r.warnings)")
XCTAssertTrue(r.warnings.contains { $0.hasPrefix("Row 3:") }, "0 is outside the scale too")
}

func testABlankTargetStaysNilRatherThanBecomingZero() throws {
let r = try parse("lift_program_filled.xlsx")
let fly = r.programs[1].lines[1]
Expand Down Expand Up @@ -117,7 +142,7 @@ final class LiftProgramSheetImporterTests: XCTestCase {
let grid = try XlsxSheet.grids(from: data).first ?? []
let headers = grid.first.map { $0.map { HeaderNorm.normalize($0) } } ?? []
for expected in ["program", "program_note", "exercise", "primary_muscle",
"secondary_muscles", "sets", "reps", "weight_kg", "rest_sec", "note"] {
"secondary_muscles", "sets", "reps", "weight_kg", "target_max_rpe", "rest_sec", "note"] {
XCTAssertTrue(headers.contains(expected),
"the shipped template lost the \"\(expected)\" column: \(headers)")
}
Expand Down Expand Up @@ -151,6 +176,14 @@ final class LiftProgramSheetImporterTests: XCTestCase {
XCTAssertTrue(xml.contains("selectUnlockedCells=\"0\""), "the data cells must be typable")
}

/// The max RPE column only accepts the scale, so a typo is caught while filling the sheet rather
/// than surfacing as an import warning later.
func testTheShippedTemplateLimitsMaxRpeToTheScale() throws {
let xml = try templateSheetXml()
XCTAssertTrue(xml.contains("type=\"decimal\" operator=\"between\""), "max RPE must be validated")
XCTAssertTrue(xml.contains("<formula1>1</formula1><formula2>10</formula2>"), "and bounded to 1-10")
}

// MARK: - Refusals

func testAFileWithNoExerciseColumnIsRefusedWithThatReason() {
Expand Down
31 changes: 27 additions & 4 deletions Packages/WhoopStore/Sources/WhoopStore/LiftLogStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,8 @@ public struct LiftProgramItemRow: Equatable, Codable, Sendable {
/// 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.
/// Max RPE on the user's own 1-10 scale: the ceiling the session shows grey, and what a set left
/// unrated records as its rating.
public var targetRpe: Double?
/// Planned working weight in kilograms (v41). A program line plans a weight, not only reps.
public var targetWeightKg: Double?
Expand Down Expand Up @@ -586,6 +587,23 @@ extension WhoopStore {
}
}

/// Delete individual sets, as editing a finished session does when a set is removed. The session
/// itself stays. Returns how many rows went.
///
/// The Kotlin twin is `DeviceRegistryDao.deleteLiftSets`.
@discardableResult
public func deleteLiftSets(ids: [String]) async throws -> Int {
guard !ids.isEmpty else { return 0 }
return try syncWrite { db in
var n = 0
for id in ids {
try db.execute(sql: "DELETE FROM liftSet WHERE id = ?", arguments: [id])
n += db.changesCount
}
return n
}
}

// MARK: Sets

/// Upsert sets by `id`. Called as each set is logged, so a session in progress is durable set by
Expand Down Expand Up @@ -644,7 +662,9 @@ extension WhoopStore {
/// 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.
/// `startTs`) so a running session never pre-fills from itself. Sets with zero reps were not
/// performed (`LiftMetrics.isPerformed`) and are skipped, so a discarded set never becomes the next
/// session's grey numbers, and a session holding only such sets is not "last time" for the exercise.
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
Expand All @@ -654,12 +674,13 @@ extension WhoopStore {
SELECT s.sessionId FROM liftSet s
JOIN liftSession sess ON sess.id = s.sessionId
WHERE s.deviceId = ? AND s.exercise = ? AND sess.startTs < ?
AND (s.reps IS NULL OR s.reps <> 0)
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 = ?
WHERE sessionId = ? AND exercise = ? AND (reps IS NULL OR reps <> 0)
ORDER BY ord ASC
""", arguments: [sessionId, exercise]).map(LiftSetRow.decode)
}
Expand All @@ -677,7 +698,8 @@ extension WhoopStore {
/// `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
/// **Warm-ups and sets with zero reps (never performed, `LiftMetrics.isPerformed`) 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
Expand All @@ -695,6 +717,7 @@ extension WhoopStore {
WHERE s.deviceId = ?
AND sess.startTs >= ? AND sess.startTs <= ?
AND s.isWarmup = 0
AND (s.reps IS NULL OR s.reps <> 0)
""", arguments: [deviceId, fromTs, toTs])

var direct: [LiftMuscle: Int] = [:]
Expand Down
Loading
Loading