Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a95291b
lift log: programs, the exercise vocabulary and muscle classification
UtkuDenizAltiok Aug 26, 2026
bf99560
lift log: the session loop, the rest timer and strap double-tap
UtkuDenizAltiok Aug 26, 2026
87688bd
lift log: fix the tap-anywhere mistake, and move set entry into the rest
UtkuDenizAltiok Aug 26, 2026
d852d3c
lift log: the metrics, and the session detail screen that shows them
UtkuDenizAltiok Aug 26, 2026
d703679
lift log: a workout sheet, and a session that outlives its screen
UtkuDenizAltiok Sep 1, 2026
77520d4
lift log: forget an exercise, dismiss the keyboard, and stop phantom …
UtkuDenizAltiok Sep 1, 2026
8db33ae
lift log: restore the warm-up marker
UtkuDenizAltiok Sep 2, 2026
95ac411
lift log: describe the targets the editor actually has
UtkuDenizAltiok Sep 2, 2026
dbcdc8d
lift log: keep the session on the machine you're at, and record what …
UtkuDenizAltiok Sep 9, 2026
85eecd4
lift log: fix the wrapped Set heading, and show HR and the set's numbers
UtkuDenizAltiok Sep 9, 2026
5427e0f
lift log: draw the rest between the sets, and put HR on the bar
UtkuDenizAltiok Sep 9, 2026
f9dbfd4
lift log: put the running session on the Lock Screen
UtkuDenizAltiok Sep 9, 2026
57f8f42
lift log: keep the heart rate on screen even when it is not reading
UtkuDenizAltiok Sep 9, 2026
10d57e9
lift log: build a program from a spreadsheet
UtkuDenizAltiok Sep 9, 2026
5323f22
lift log: make the spreadsheet import survive a real user's file
UtkuDenizAltiok Sep 9, 2026
e1feade
lift log: fix a divergent metric, and bound the import so it cannot h…
UtkuDenizAltiok Sep 9, 2026
c85ebba
lift log: delete dead store API, leaving one computed read in the store
UtkuDenizAltiok Sep 9, 2026
2618ae4
lift log: let a decimal weight be typed, and keep two decimals
UtkuDenizAltiok Sep 9, 2026
048d608
lift log: let a session be discarded or deleted, and bound the note l…
UtkuDenizAltiok Sep 10, 2026
d6b07a2
lift log: stop the weekly bar saying "done" at the floor, and lengthe…
UtkuDenizAltiok Sep 10, 2026
25df4e2
lift log: add or drop a set mid-session, and keep the program in step
UtkuDenizAltiok Sep 10, 2026
b852ea4
lift log: pin that the previous build's in-flight session still reads
UtkuDenizAltiok Sep 10, 2026
007c3b4
lift log: let any set's numbers be typed at any time
UtkuDenizAltiok Sep 11, 2026
1c45b55
lift log: translate the screens that were still rendering English
UtkuDenizAltiok Sep 11, 2026
3e2d930
lift log: widen the double-tap de-duplication, and stop the estimates…
UtkuDenizAltiok Sep 11, 2026
8257b45
lift log: drop work-vs-rest, and say what the volume total can be com…
UtkuDenizAltiok Sep 11, 2026
4553eb3
lift log: drop two APIs nothing in the app calls
UtkuDenizAltiok Sep 13, 2026
2d1a334
lift log: dropping a set drops what was typed into it
UtkuDenizAltiok Sep 14, 2026
7ad5822
lift log: log every point where a double-tap can be dropped
UtkuDenizAltiok Sep 14, 2026
45caa74
lift log: finish a session by asking, and let a finished one be edited
UtkuDenizAltiok Sep 14, 2026
fed714c
lift log: do not file a session that saved no sets
ryanbr Sep 15, 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
247 changes: 247 additions & 0 deletions Packages/StrandAnalytics/Sources/StrandAnalytics/LiftMetrics.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import Foundation
import WhoopStore

// Training metrics for the Lift Log.
//
// Every figure here is arithmetic the user can redo by hand from their own logged sets. That is the
// whole design constraint: NOOP shows a few honest numbers rather than one invented score, because a
// composite "workout score out of 100" feels satisfying and tells you nothing about what to change.
//
// PURE. No store, no clock, no UI — the inputs are rows and the outputs are numbers, so the whole
// surface is unit-testable with no strap, no database and no simulator.
//
// WHAT IS DELIBERATELY ABSENT, and must stay absent:
//
// • Anything that feeds `workout.strain` or daily Effort. NOOP's strain is HR-measured (Karvonen
// %HRR -> Edwards TRIMP). There is no validated public path from typed sets/reps/weight to a
// cardiovascular-strain equivalent — WHOOP's own muscular load runs velocity-based algorithms
// over strap accelerometer/gyroscope under an unpublished model — and deriving one here is the
// exact case `CLAUDE.md` warns about after the withdrawn PPG->HR estimate (#194).
//
// • Per-exercise muscle weightings ("bench press = 0.7 triceps"). There is no published table to
// take them from. Inventing one would make every downstream per-muscle figure fiction wearing
// the costume of precision. The direct/indirect split is the resolution the evidence supports.
//
// • Acute:chronic workload ratios or any injury-risk / overtraining warning. The construct's
// validity is actively disputed, and a health warning from a non-medical app is either ignored
// or believed — both bad.

public enum LiftMetrics {

// MARK: - Volume load (tonnage)

/// Σ (weight × reps) over WORKING sets, in kilograms. Nil when nothing countable was logged.
///
/// Warm-ups are excluded because the training literature counts working sets, and a warm-up
/// double counted as volume would flatter every session. A set missing either its weight or its
/// reps contributes nothing rather than a guess.
///
/// Good for: tracking progression WITHIN one exercise across weeks. Not comparable between
/// exercises — 100 kg of leg press is not 100 kg of squat — and not comparable between people.
public static func volumeLoadKg(_ sets: [LiftSetRow]) -> Double? {
let total = sets.reduce(into: 0.0) { sum, s in
guard !s.isWarmup, let w = s.weightKg, let r = s.reps, w > 0, r > 0 else { return }
sum += w * Double(r)
}
return total > 0 ? total : nil
}

// MARK: - Session load (Foster sRPE-TL)

/// Session RPE × duration in minutes.
///
/// Foster's session-RPE training load. The reason it earns a place next to volume: it is
/// validated across BOTH resistance and endurance training, which makes it the only figure in
/// the app that puts a leg day and a run on one comparable scale.
///
/// Nil when the session was not rated — a skipped rating must never be read as an effortless 0.
public static func sessionLoad(sessionRpe: Double?, durationSec: Int) -> Double? {
guard let rpe = sessionRpe, rpe > 0, durationSec > 0 else { return nil }
return rpe * (Double(durationSec) / 60.0)
}

// MARK: - Estimated one-rep max (Epley)

/// The rep ceiling above which a 1RM estimate stops being worth showing.
///
/// Every 1RM formula is a straight-line fit to a curved relationship, and the error grows with
/// reps: a 20-rep set says far more about endurance than about maximal strength. Twelve is the
/// conventional upper bound where the formulas are least unreliable.
public static let oneRepMaxRepCeiling = 12

/// Epley: `w × (1 + reps/30)`. Nil for a set that cannot support an estimate.
///
/// A single rep returns the weight itself — the formula's own +3.3% at one rep is an artefact of
/// the fit, not a claim that a single you just completed was really 3% heavier.
public static func estimatedOneRepMaxKg(weightKg: Double?, reps: Int?) -> Double? {
guard let w = weightKg, let r = reps, w > 0, r > 0, r <= oneRepMaxRepCeiling else { return nil }
guard r > 1 else { return w }
return w * (1.0 + Double(r) / 30.0)
}

// MARK: - Per-exercise summary

public struct ExerciseSummary: Equatable {
public let exercise: String
/// Working sets only — the tally the dose-response literature is built on.
public let workingSets: Int
public let warmupSets: Int
public let volumeKg: Double?
/// The session's best set for this exercise, ranked by ESTIMATED 1RM rather than by raw
/// weight: 90 kg × 10 is a better set than 100 kg × 5, and ranking by weight alone would
/// hide that. Falls back to the heaviest set when no set supports an estimate.
public let bestWeightKg: Double?
public let bestReps: Int?
public let bestEstimatedOneRepMaxKg: Double?

public init(exercise: String, workingSets: Int, warmupSets: Int, volumeKg: Double?,
bestWeightKg: Double?, bestReps: Int?, bestEstimatedOneRepMaxKg: Double?) {
self.exercise = exercise
self.workingSets = workingSets
self.warmupSets = warmupSets
self.volumeKg = volumeKg
self.bestWeightKg = bestWeightKg
self.bestReps = bestReps
self.bestEstimatedOneRepMaxKg = bestEstimatedOneRepMaxKg
}
}

/// 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.
public static func perExercise(_ sets: [LiftSetRow]) -> [ExerciseSummary] {
var order: [String] = []
var grouped: [String: [LiftSetRow]] = [:]
for s in sets.sorted(by: { $0.ord < $1.ord }) {
if grouped[s.exercise] == nil { order.append(s.exercise) }
grouped[s.exercise, default: []].append(s)
}
return order.map { name in
let rows = grouped[name] ?? []
let working = rows.filter { !$0.isWarmup }

// Rank by estimated 1RM where possible; otherwise by raw weight, so an exercise logged
// only at high reps still reports a best set rather than nothing.
let best = working.max { a, b in
let ea = estimatedOneRepMaxKg(weightKg: a.weightKg, reps: a.reps)
let eb = estimatedOneRepMaxKg(weightKg: b.weightKg, reps: b.reps)
if let ea, let eb { return ea < eb }
if ea != nil { return false }
if eb != nil { return true }
return (a.weightKg ?? 0) < (b.weightKg ?? 0)
}
return ExerciseSummary(
exercise: name,
workingSets: working.count,
warmupSets: rows.count - working.count,
volumeKg: volumeLoadKg(rows),
bestWeightKg: best?.weightKg,
bestReps: best?.reps,
bestEstimatedOneRepMaxKg: estimatedOneRepMaxKg(weightKg: best?.weightKg,
reps: best?.reps))
}
}

// MARK: - RPE profile

public struct RpeProfile: Equatable {
public let mean: Double?
public let ratedSets: Int
public let unratedSets: Int
public let setsAtOrAboveThreshold: Int
public let threshold: Double

public init(mean: Double?, ratedSets: Int, unratedSets: Int,
setsAtOrAboveThreshold: Int, threshold: Double) {
self.mean = mean
self.ratedSets = ratedSets
self.unratedSets = unratedSets
self.setsAtOrAboveThreshold = setsAtOrAboveThreshold
self.threshold = threshold
}
}

/// The default "this set was close to failure" line. Informational only.
public static let hardSetRpeThreshold = 8.0

/// How close to failure the working sets were.
///
/// Reported SEPARATELY from the set counts and never as a filter on them. The tempting move is
/// to count only sets at RPE >= 7 toward a muscle's weekly total, since proximity to failure is
/// what makes a set count biologically. Doing that would compare a smaller number against
/// reference doses derived from UNFILTERED working-set counts — quietly changing the scale.
/// `unratedSets` is surfaced so a mean computed from three of twelve sets is visibly thin.
public static func rpeProfile(_ sets: [LiftSetRow],
threshold: Double = hardSetRpeThreshold) -> RpeProfile {
let working = sets.filter { !$0.isWarmup }
let rated = working.compactMap(\.rpe)
let mean = rated.isEmpty ? nil : rated.reduce(0, +) / Double(rated.count)
return RpeProfile(mean: mean,
ratedSets: rated.count,
unratedSets: working.count - rated.count,
setsAtOrAboveThreshold: rated.filter { $0 >= threshold }.count,
threshold: threshold)
}

// MARK: - Sets per muscle

public struct MuscleCounts: Equatable {
/// direct × 1.0 + indirect × 0.5 — the published fractional method.
public let fractional: [LiftMuscle: Double]
public let direct: [LiftMuscle: Int]
public let indirect: [LiftMuscle: Int]

public init(fractional: [LiftMuscle: Double], direct: [LiftMuscle: Int],
indirect: [LiftMuscle: Int]) {
self.fractional = fractional
self.direct = direct
self.indirect = indirect
}
}

/// Fractional set counts per muscle over the given sets.
///
/// The 0.5 for an indirect set is NOT a house convention: the 2025 Sports Medicine dose-response
/// meta-regression compared counting a secondary mover's set as 1.0 ("total"), 0.5
/// ("fractional") and 0.0 ("direct"), found the evidence strongest for fractional, and used it
/// in its primary models. The reference doses in `ReferenceDose` were derived under that same
/// 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.
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 {
if let p = s.primaryMuscle {
direct[p, default: 0] += 1
fractional[p, default: 0] += LiftMuscle.directSetCredit
}
for m in s.secondaryMuscles where m != s.primaryMuscle {
indirect[m, default: 0] += 1
fractional[m, default: 0] += LiftMuscle.indirectSetCredit
}
}
return MuscleCounts(fractional: fractional, direct: direct, indirect: indirect)
}

// MARK: - The reference band

/// Weekly fractional sets per muscle, from the same dose-response meta-regression the 0.5
/// credit comes from.
///
/// PRESENTED AS A BAND WITH ITS SOURCE NAMED, NEVER AS A PERSONAL PRESCRIPTION. NOOP is not a
/// medical device and does not tell anyone what their body needs; it says what the research
/// associates with growth and leaves the conclusion to the reader.
public enum ReferenceDose {
/// Below roughly this, hypertrophy is not reliably detectable.
public static let hypertrophyMinimumSetsPerWeek = 4.0
/// Strength keeps improving from a single weekly set.
public static let strengthMinimumSetsPerWeek = 1.0
/// Beyond roughly this, added volume stops reliably beating the smallest detectable effect
/// FOR STRENGTH. Hypertrophy has no identified ceiling — gains continue with strongly
/// diminishing returns, and the uncertainty widens as volume rises.
public static let strengthPlateauSetsPerWeek = 4.0
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import XCTest
import GRDB
import WhoopStore
@testable import StrandAnalytics

/// The per-muscle set count is computed TWICE, by two different pieces of code, and both are shown
/// to the user:
///
/// * `WhoopStore.liftSetCounts` — SQL, feeds the Lift Log hub's "last 7 days" card;
/// * `LiftMetrics.muscleCounts` — in memory, feeds a finished session's detail sheet.
///
/// It is the load-bearing figure of the whole feature — the one the reference doses are compared
/// against — so the two disagreeing would mean two screens reporting different numbers for the same
/// sets, with nothing to notice. They are pinned against each other here rather than each being
/// pinned to its own expectation, because agreeing with a literal is not the same as agreeing with
/// each other.
///
/// This test exists because they DID differ: the in-memory version excluded a muscle listed both as
/// primary and secondary, and the SQL version did not. Nothing showed it, because the write path
/// strips the primary on the way in — so the divergence was invisible until some future writer
/// forgot to. The malformed row below is written with raw SQL precisely because the public API
/// cannot produce one.
final class LiftMetricsStoreAgreementTests: XCTestCase {

private let day = 1_700_000_000

/// 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 {
let store = try await WhoopStore.inMemory()
let writer = store.registryWriter
try await writer.write { db in
try db.execute(sql: """
INSERT INTO liftSession (id, deviceId, startTs, endTs, sport, programId, programName,
sessionRpe, note)
VALUES ('s1', 'dev', ?, ?, 'Strength Training', NULL, NULL, NULL, NULL)
""", arguments: [self.day, self.day + 3600])
for (i, r) in rows.enumerated() {
try db.execute(sql: """
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])
}
}
return store
}

/// The same rows, as `LiftMetrics` would receive them from the store.
private func rows(_ store: WhoopStore) async throws -> [LiftSetRow] {
try await store.liftSets(sessionId: "s1")
}

private func assertAgree(_ store: WhoopStore,
file: StaticString = #filePath, line: UInt = #line) async throws {
let sql = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1)
let memory = LiftMetrics.muscleCounts(try await rows(store))
XCTAssertEqual(sql.direct, memory.direct, "direct counts diverged", file: file, line: line)
XCTAssertEqual(sql.indirect, memory.indirect, "indirect counts diverged", file: file, line: line)
XCTAssertEqual(sql.fractional, memory.fractional, "fractional counts diverged", file: file, line: line)
}

/// The ordinary shape: a primary and two distinct secondaries.
func testBothImplementationsAgreeOnAWellFormedSet() async throws {
let store = try await store([(primary: "chest", secondary: "frontDelts,triceps", warmup: 0)])
try await assertAgree(store)

let counts = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1)
XCTAssertEqual(counts.fractional[.chest], LiftMuscle.directSetCredit)
XCTAssertEqual(counts.fractional[.triceps], LiftMuscle.indirectSetCredit)
}

/// THE ONE THAT WAS BROKEN. A row that lists its own primary among the secondaries must be
/// credited once, as direct, by BOTH — not 1.0 by one screen and 1.5 by the other.
func testBothImplementationsAgreeWhenARowListsItsPrimaryAsASecondary() async throws {
let store = try await store([(primary: "chest", secondary: "chest,triceps", warmup: 0)])
try await assertAgree(store)

let counts = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1)
XCTAssertEqual(counts.direct[.chest], 1)
XCTAssertNil(counts.indirect[.chest], "the primary must not also be counted as indirect")
XCTAssertEqual(counts.fractional[.chest], LiftMuscle.directSetCredit,
"1.0, never 1.5 — the same muscle cannot be worked twice by one set")
}

/// Warm-ups are excluded on both sides. They are excluded from volume and the per-muscle counts
/// by design, so a difference here would inflate the figure the doses are compared against.
func testBothImplementationsAgreeThatWarmUpsDoNotCount() async throws {
let store = try await store([
(primary: "quads", secondary: "glutes", warmup: 1),
(primary: "quads", secondary: "glutes", warmup: 0),
])
try await assertAgree(store)

let counts = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1)
XCTAssertEqual(counts.direct[.quads], 1, "only the working set counts")
}

/// An unclassified set contributes nothing rather than defaulting into a bucket.
func testBothImplementationsAgreeOnAnUnclassifiedSet() async throws {
let store = try await store([(primary: nil, secondary: "", warmup: 0)])
try await assertAgree(store)

let counts = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1)
XCTAssertTrue(counts.fractional.isEmpty, "no muscle was named, so no muscle is credited")
}

/// A token no longer in the vocabulary is ignored by both rather than crashing or counting.
func testBothImplementationsAgreeOnAnUnknownToken() async throws {
let store = try await store([(primary: "shoulders", secondary: "pecs,triceps", warmup: 0)])
try await assertAgree(store)

let counts = try await store.liftSetCounts(deviceId: "dev", fromTs: day - 1, toTs: day + 1)
XCTAssertEqual(counts.indirect[.triceps], 1, "the recognisable half still counts")
XCTAssertTrue(counts.direct.isEmpty)
}
}
Loading
Loading