From 7ed9af3cd20a0ab1db846f5410fffde0a23a5473 Mon Sep 17 00:00:00 2001 From: DX23876 <176692557+DX23876@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:52:06 +0200 Subject: [PATCH 1/2] test(training): pin Training Load's lane readings with an oracle Move the detached computation in TrainingLoadModel.load into a static prepare function without changing a line of its arithmetic, so a fixture can run it. The pinned figures cover rated and unrated sets, an unknown cardio day inside and outside the window, a duplicate awaiting review and a session rated twice. Analysis migration required: no --- Strand/Screens/TrainingLoadView.swift | 379 ++++++++++++----------- StrandTests/TrainingLoadLanesTests.swift | 140 +++++++++ 2 files changed, 335 insertions(+), 184 deletions(-) create mode 100644 StrandTests/TrainingLoadLanesTests.swift diff --git a/Strand/Screens/TrainingLoadView.swift b/Strand/Screens/TrainingLoadView.swift index 39324fe807..713e7d9f88 100644 --- a/Strand/Screens/TrainingLoadView.swift +++ b/Strand/Screens/TrainingLoadView.swift @@ -45,7 +45,7 @@ final class TrainingLoadModel: ObservableObject { var id: String { day } } - private struct Prepared: Sendable { + struct Prepared: Sendable { let strength: Lane let cardio: Lane let session: Lane @@ -99,198 +99,18 @@ final class TrainingLoadModel: ObservableObject { async let ratings = repo.sessionRPEEntries(from: from, to: now + 86_400) async let strengthHistoryRead = repo.resolvedStrengthHistory(days: Self.historyDays) let strengthHistory = await strengthHistoryRead - let strengthWorkouts = strengthHistory.workouts - let templates = strengthHistory.templates let fusion = await fusedSessions let unified = fusion.sessions let cardioResolution = await repo.cardioLoads(for: unified) - let cardioLoads = cardioResolution.loads let rpeEntries = await ratings let dailyRows = repo.days let vo2 = await Self.vo2maxReadings(repo: repo) let today = Repository.localDayKey(Date()) let prepared = await Task.detached(priority: .userInitiated) { () -> Prepared in - let strengthByDay = StrengthSession.weightedSetsByDay(strengthWorkouts, - tzOffsetSeconds: offset) - let cardioSeries = Self.cardioDailyLoad(sessions: unified, loads: cardioLoads, - duplicates: cardioResolution.duplicateSessionIds, - tzOffsetSeconds: offset) - let cardioByDay = cardioSeries.byDay - // Days that held real training the data could not price. They leave BOTH comparison - // windows rather than counting as rest, so a gap in our measurement is never reported as - // a drop in the wearer's training. - let cardioUnknown = cardioSeries.unknownDays - - var durationByStart: [Int: Double] = [:] - var canonicalIdByStart: [Int: String] = [:] - for session in unified { - let seconds = session.row.durationS ?? Double(session.row.endTs - session.row.startTs) - if seconds > 0 { - durationByStart[session.row.startTs] = seconds - for component in session.components { durationByStart[component.row.startTs] = seconds } - } - canonicalIdByStart[session.row.startTs] = session.id - for component in session.components { canonicalIdByStart[component.row.startTs] = session.id } - } - for workout in strengthWorkouts where durationByStart[workout.startTs] == nil { - if let seconds = workout.durationS { durationByStart[workout.startTs] = seconds } - } - - let ratings = Self.canonicalRatings(entries: rpeEntries, canonicalIdByStart: canonicalIdByStart) - let ratingBySession = Dictionary(ratings.map { entry in - let key = entry.sessionId ?? canonicalIdByStart[entry.startTs] ?? "start|\(entry.startTs)" - return (key, entry) - }, uniquingKeysWith: { _, newest in newest }) - var sessionByDay: [String: Double] = [:] - var possibleSessionKeysByDay: [String: Set] = [:] - for session in unified { - let day = AnalyticsEngine.dayString(session.row.startTs, offsetSec: offset) - possibleSessionKeysByDay[day, default: []].insert(session.id) - } - for workout in strengthWorkouts { - let day = AnalyticsEngine.dayString(workout.startTs, offsetSec: offset) - let key = canonicalIdByStart[workout.startTs] ?? "start|\(workout.startTs)" - possibleSessionKeysByDay[day, default: []].insert(key) - } - var ratedSessionKeysByDay: [String: Set] = [:] - for entry in ratings { - guard let seconds = durationByStart[entry.startTs], seconds > 0 else { continue } - let day = AnalyticsEngine.dayString(entry.startTs, offsetSec: offset) - sessionByDay[day, default: 0] += entry.rpe * seconds / 60 - let key = entry.sessionId ?? canonicalIdByStart[entry.startTs] ?? "start|\(entry.startTs)" - ratedSessionKeysByDay[day, default: []].insert(key) - } - let sessionUnknown = Set(possibleSessionKeysByDay.compactMap { day, possibleKeys in - let ratedKeys = ratedSessionKeysByDay[day] ?? [] - return possibleKeys.isSubset(of: ratedKeys) ? nil : day - }) - - let cutoff = WeeklyDigestEngine.addDays(today, -6) - let recentStrength = strengthWorkouts.filter { - let day = AnalyticsEngine.dayString($0.startTs, offsetSec: offset) - return day >= cutoff && day <= today - } - let pooledStrength = StrengthSession.strengthLoad(recentStrength) - // A session skipped because another one already priced the same minutes is NOT a session - // with missing heart rate, so it must not widen the coverage denominator. - let recentCardio = unified.filter { - let day = AnalyticsEngine.dayString($0.row.startTs, offsetSec: offset) - return day >= cutoff && day <= today - && ($0.row.endTs - $0.row.startTs) >= Repository.cardioLoadMinimumSeconds - && !cardioResolution.duplicateSessionIds.contains($0.id) - } - let possible = possibleSessionKeysByDay.filter { $0.key >= cutoff && $0.key <= today } - .values.reduce(0) { $0 + $1.count } - let measured = ratedSessionKeysByDay.filter { $0.key >= cutoff && $0.key <= today } - .values.reduce(0) { $0 + $1.count } - - // Load, adaptation and recovery stay separate. The latter two may provide context, but do - // not turn a high load into a positive or medical verdict. - let response = TrainingStatusModel.strengthResponse(workouts: strengthWorkouts, - templates: templates, through: today, - tzOffsetSeconds: offset) - let recovery = TrainingStatusModel.recovery(days: dailyRows, through: today) - let strengthRelative = TrainingLoad.relativeLoad(dailyByDay: strengthByDay, through: today) - let cardioRelative = TrainingLoad.relativeLoad(dailyByDay: cardioByDay, through: today, - unknownDays: cardioUnknown) - let sessionRelative = TrainingLoad.relativeLoad(dailyByDay: sessionByDay, through: today, - unknownDays: sessionUnknown) - let history = TrainingStatusModel.weeklyHistory(weeks: 8, through: today, - strengthDaily: strengthByDay, - cardioDaily: cardioByDay, - cardioUnknownDays: cardioUnknown, - workouts: strengthWorkouts, templates: templates, - days: dailyRows, tzOffsetSeconds: offset) - var ratios: [RatioPoint] = [] - var ratioDay = WeeklyDigestEngine.addDays(today, -55) - for _ in 0..<56 { - ratios.append(RatioPoint(day: ratioDay, - strength: TrainingLoad.trend(dailyByDay: strengthByDay, through: ratioDay)?.ratio, - cardio: TrainingLoad.trend(dailyByDay: cardioByDay, through: ratioDay, - unknownDays: cardioUnknown)?.ratio)) - ratioDay = WeeklyDigestEngine.addDays(ratioDay, 1) - } - let vo2max = TrainingStatusModel.vo2maxResponse(readings: vo2, through: today) - let strengthAdaptation = TrainingStatusModel.strengthAdaptation(response) - let cardiovascularAdaptation = TrainingStatusModel.cardiovascularAdaptation(vo2max) - let sustained = TrainingStatusModel.sustainedOverreaching(history: history, strengthResponse: response, - cardioDirection: vo2max.direction, - recovery: recovery) - let provisionalStrengthRing: ProvisionalStrengthRingReading? - if strengthRelative.trend == nil { - let recentResolved = strengthHistory.sessions.filter { - let day = AnalyticsEngine.dayString($0.startTs, offsetSec: offset) - return day >= cutoff && day <= today - } - let loads: [Double?] = recentResolved.map { session in - let canonicalKey = canonicalIdByStart[session.startTs] - let rating = ratingBySession[session.id] - ?? canonicalKey.flatMap { ratingBySession[$0] } - ?? ratingBySession["start|\(session.startTs)"] - guard let rpe = rating?.rpe, session.durationS > 0 else { return nil } - return rpe * session.durationS / 60 - } - let start = Int(Calendar(identifier: .gregorian).date( - byAdding: .day, value: -6, - to: Calendar(identifier: .gregorian).startOfDay(for: Date(timeIntervalSince1970: TimeInterval(now))))? - .timeIntervalSince1970 ?? Double(now - 6 * 86_400)) - let muscle = DetailedMuscleLoadSnapshot.volume(history: strengthHistory, - from: start, to: now) - provisionalStrengthRing = TrainingLoad.provisionalStrengthRing( - sessionLoads: loads, weightedMuscleSets: muscle.byMuscle, - hasUnmappedSets: muscle.hasUnmappedSets) - } else { - provisionalStrengthRing = nil - } - - return Prepared( - strength: Lane(sevenDayTotal: Self.lastSeven(strengthByDay, through: today), - sevenDayWorkingSets: recentStrength.flatMap { - $0.exercises.flatMap(\.workingSets) - }.count, - trend: strengthRelative.trend, - relative: strengthRelative, - isLowerBound: false, - distribution: TrainingLoad.distribution(dailyByDay: strengthByDay, through: today), - weekOverWeek: TrainingLoad.weekOverWeek(dailyByDay: strengthByDay, through: today), - measuredCount: pooledStrength.ratedSets, - possibleCount: pooledStrength.workingSets, - status: Self.relativeStatus(strengthRelative)), - cardio: Lane(sevenDayTotal: Self.lastSeven(cardioByDay, through: today), - sevenDayWorkingSets: 0, - trend: cardioRelative.trend, - relative: cardioRelative, - isLowerBound: Self.lastSevenContainsUnknown(cardioUnknown, through: today), - distribution: TrainingLoad.distribution(dailyByDay: cardioByDay, through: today, - unknownDays: cardioUnknown), - weekOverWeek: TrainingLoad.weekOverWeek(dailyByDay: cardioByDay, through: today, - unknownDays: cardioUnknown), - measuredCount: recentCardio.filter { - cardioLoads[$0.id] != nil - }.count, - possibleCount: recentCardio.count, - status: Self.relativeStatus(cardioRelative)), - session: Lane(sevenDayTotal: Self.lastSeven(sessionByDay, through: today), - sevenDayWorkingSets: 0, - trend: sessionRelative.trend, - relative: sessionRelative, - isLowerBound: Self.lastSevenContainsUnknown(sessionUnknown, through: today), - distribution: TrainingLoad.distribution(dailyByDay: sessionByDay, through: today, - unknownDays: sessionUnknown), - weekOverWeek: TrainingLoad.weekOverWeek(dailyByDay: sessionByDay, through: today, - unknownDays: sessionUnknown), - measuredCount: measured, possibleCount: possible, status: nil), - response: response, - vo2max: vo2max, - recovery: recovery, - history: history, - ratios: ratios, - sustained: sustained, - cardioMeasured: cardioSeries.measured, - strengthAdaptation: strengthAdaptation, - cardiovascularAdaptation: cardiovascularAdaptation, - provisionalStrengthRing: provisionalStrengthRing) + Self.prepare(strengthHistory: strengthHistory, unified: unified, + cardioResolution: cardioResolution, rpeEntries: rpeEntries, + dailyRows: dailyRows, vo2: vo2, today: today, now: now, offset: offset) }.value guard !Task.isCancelled else { return } @@ -331,6 +151,197 @@ final class TrainingLoadModel: ObservableObject { loaded = true } + nonisolated static func prepare(strengthHistory: ResolvedStrengthHistory, + unified: [UnifiedTrainingSession], + cardioResolution: TrainingCardioLoadResolution, + rpeEntries: [SessionRPEEntry], dailyRows: [DailyMetric], + vo2: [VO2maxReading], today: String, now: Int, + offset: Int) -> Prepared { + let strengthWorkouts = strengthHistory.workouts + let templates = strengthHistory.templates + let cardioLoads = cardioResolution.loads + let strengthByDay = StrengthSession.weightedSetsByDay(strengthWorkouts, + tzOffsetSeconds: offset) + let cardioSeries = Self.cardioDailyLoad(sessions: unified, loads: cardioLoads, + duplicates: cardioResolution.duplicateSessionIds, + tzOffsetSeconds: offset) + let cardioByDay = cardioSeries.byDay + // Days that held real training the data could not price. They leave BOTH comparison + // windows rather than counting as rest, so a gap in our measurement is never reported as + // a drop in the wearer's training. + let cardioUnknown = cardioSeries.unknownDays + + var durationByStart: [Int: Double] = [:] + var canonicalIdByStart: [Int: String] = [:] + for session in unified { + let seconds = session.row.durationS ?? Double(session.row.endTs - session.row.startTs) + if seconds > 0 { + durationByStart[session.row.startTs] = seconds + for component in session.components { durationByStart[component.row.startTs] = seconds } + } + canonicalIdByStart[session.row.startTs] = session.id + for component in session.components { canonicalIdByStart[component.row.startTs] = session.id } + } + for workout in strengthWorkouts where durationByStart[workout.startTs] == nil { + if let seconds = workout.durationS { durationByStart[workout.startTs] = seconds } + } + + let ratings = Self.canonicalRatings(entries: rpeEntries, canonicalIdByStart: canonicalIdByStart) + let ratingBySession = Dictionary(ratings.map { entry in + let key = entry.sessionId ?? canonicalIdByStart[entry.startTs] ?? "start|\(entry.startTs)" + return (key, entry) + }, uniquingKeysWith: { _, newest in newest }) + var sessionByDay: [String: Double] = [:] + var possibleSessionKeysByDay: [String: Set] = [:] + for session in unified { + let day = AnalyticsEngine.dayString(session.row.startTs, offsetSec: offset) + possibleSessionKeysByDay[day, default: []].insert(session.id) + } + for workout in strengthWorkouts { + let day = AnalyticsEngine.dayString(workout.startTs, offsetSec: offset) + let key = canonicalIdByStart[workout.startTs] ?? "start|\(workout.startTs)" + possibleSessionKeysByDay[day, default: []].insert(key) + } + var ratedSessionKeysByDay: [String: Set] = [:] + for entry in ratings { + guard let seconds = durationByStart[entry.startTs], seconds > 0 else { continue } + let day = AnalyticsEngine.dayString(entry.startTs, offsetSec: offset) + sessionByDay[day, default: 0] += entry.rpe * seconds / 60 + let key = entry.sessionId ?? canonicalIdByStart[entry.startTs] ?? "start|\(entry.startTs)" + ratedSessionKeysByDay[day, default: []].insert(key) + } + let sessionUnknown = Set(possibleSessionKeysByDay.compactMap { day, possibleKeys in + let ratedKeys = ratedSessionKeysByDay[day] ?? [] + return possibleKeys.isSubset(of: ratedKeys) ? nil : day + }) + + let cutoff = WeeklyDigestEngine.addDays(today, -6) + let recentStrength = strengthWorkouts.filter { + let day = AnalyticsEngine.dayString($0.startTs, offsetSec: offset) + return day >= cutoff && day <= today + } + let pooledStrength = StrengthSession.strengthLoad(recentStrength) + // A session skipped because another one already priced the same minutes is NOT a session + // with missing heart rate, so it must not widen the coverage denominator. + let recentCardio = unified.filter { + let day = AnalyticsEngine.dayString($0.row.startTs, offsetSec: offset) + return day >= cutoff && day <= today + && ($0.row.endTs - $0.row.startTs) >= Repository.cardioLoadMinimumSeconds + && !cardioResolution.duplicateSessionIds.contains($0.id) + } + let possible = possibleSessionKeysByDay.filter { $0.key >= cutoff && $0.key <= today } + .values.reduce(0) { $0 + $1.count } + let measured = ratedSessionKeysByDay.filter { $0.key >= cutoff && $0.key <= today } + .values.reduce(0) { $0 + $1.count } + + // Load, adaptation and recovery stay separate. The latter two may provide context, but do + // not turn a high load into a positive or medical verdict. + let response = TrainingStatusModel.strengthResponse(workouts: strengthWorkouts, + templates: templates, through: today, + tzOffsetSeconds: offset) + let recovery = TrainingStatusModel.recovery(days: dailyRows, through: today) + let strengthRelative = TrainingLoad.relativeLoad(dailyByDay: strengthByDay, through: today) + let cardioRelative = TrainingLoad.relativeLoad(dailyByDay: cardioByDay, through: today, + unknownDays: cardioUnknown) + let sessionRelative = TrainingLoad.relativeLoad(dailyByDay: sessionByDay, through: today, + unknownDays: sessionUnknown) + let history = TrainingStatusModel.weeklyHistory(weeks: 8, through: today, + strengthDaily: strengthByDay, + cardioDaily: cardioByDay, + cardioUnknownDays: cardioUnknown, + workouts: strengthWorkouts, templates: templates, + days: dailyRows, tzOffsetSeconds: offset) + var ratios: [RatioPoint] = [] + var ratioDay = WeeklyDigestEngine.addDays(today, -55) + for _ in 0..<56 { + ratios.append(RatioPoint(day: ratioDay, + strength: TrainingLoad.trend(dailyByDay: strengthByDay, through: ratioDay)?.ratio, + cardio: TrainingLoad.trend(dailyByDay: cardioByDay, through: ratioDay, + unknownDays: cardioUnknown)?.ratio)) + ratioDay = WeeklyDigestEngine.addDays(ratioDay, 1) + } + let vo2max = TrainingStatusModel.vo2maxResponse(readings: vo2, through: today) + let strengthAdaptation = TrainingStatusModel.strengthAdaptation(response) + let cardiovascularAdaptation = TrainingStatusModel.cardiovascularAdaptation(vo2max) + let sustained = TrainingStatusModel.sustainedOverreaching(history: history, strengthResponse: response, + cardioDirection: vo2max.direction, + recovery: recovery) + let provisionalStrengthRing: ProvisionalStrengthRingReading? + if strengthRelative.trend == nil { + let recentResolved = strengthHistory.sessions.filter { + let day = AnalyticsEngine.dayString($0.startTs, offsetSec: offset) + return day >= cutoff && day <= today + } + let loads: [Double?] = recentResolved.map { session in + let canonicalKey = canonicalIdByStart[session.startTs] + let rating = ratingBySession[session.id] + ?? canonicalKey.flatMap { ratingBySession[$0] } + ?? ratingBySession["start|\(session.startTs)"] + guard let rpe = rating?.rpe, session.durationS > 0 else { return nil } + return rpe * session.durationS / 60 + } + let start = Int(Calendar(identifier: .gregorian).date( + byAdding: .day, value: -6, + to: Calendar(identifier: .gregorian).startOfDay(for: Date(timeIntervalSince1970: TimeInterval(now))))? + .timeIntervalSince1970 ?? Double(now - 6 * 86_400)) + let muscle = DetailedMuscleLoadSnapshot.volume(history: strengthHistory, + from: start, to: now) + provisionalStrengthRing = TrainingLoad.provisionalStrengthRing( + sessionLoads: loads, weightedMuscleSets: muscle.byMuscle, + hasUnmappedSets: muscle.hasUnmappedSets) + } else { + provisionalStrengthRing = nil + } + + return Prepared( + strength: Lane(sevenDayTotal: Self.lastSeven(strengthByDay, through: today), + sevenDayWorkingSets: recentStrength.flatMap { + $0.exercises.flatMap(\.workingSets) + }.count, + trend: strengthRelative.trend, + relative: strengthRelative, + isLowerBound: false, + distribution: TrainingLoad.distribution(dailyByDay: strengthByDay, through: today), + weekOverWeek: TrainingLoad.weekOverWeek(dailyByDay: strengthByDay, through: today), + measuredCount: pooledStrength.ratedSets, + possibleCount: pooledStrength.workingSets, + status: Self.relativeStatus(strengthRelative)), + cardio: Lane(sevenDayTotal: Self.lastSeven(cardioByDay, through: today), + sevenDayWorkingSets: 0, + trend: cardioRelative.trend, + relative: cardioRelative, + isLowerBound: Self.lastSevenContainsUnknown(cardioUnknown, through: today), + distribution: TrainingLoad.distribution(dailyByDay: cardioByDay, through: today, + unknownDays: cardioUnknown), + weekOverWeek: TrainingLoad.weekOverWeek(dailyByDay: cardioByDay, through: today, + unknownDays: cardioUnknown), + measuredCount: recentCardio.filter { + cardioLoads[$0.id] != nil + }.count, + possibleCount: recentCardio.count, + status: Self.relativeStatus(cardioRelative)), + session: Lane(sevenDayTotal: Self.lastSeven(sessionByDay, through: today), + sevenDayWorkingSets: 0, + trend: sessionRelative.trend, + relative: sessionRelative, + isLowerBound: Self.lastSevenContainsUnknown(sessionUnknown, through: today), + distribution: TrainingLoad.distribution(dailyByDay: sessionByDay, through: today, + unknownDays: sessionUnknown), + weekOverWeek: TrainingLoad.weekOverWeek(dailyByDay: sessionByDay, through: today, + unknownDays: sessionUnknown), + measuredCount: measured, possibleCount: possible, status: nil), + response: response, + vo2max: vo2max, + recovery: recovery, + history: history, + ratios: ratios, + sustained: sustained, + cardioMeasured: cardioSeries.measured, + strengthAdaptation: strengthAdaptation, + cardiovascularAdaptation: cardiovascularAdaptation, + provisionalStrengthRing: provisionalStrengthRing) + } + func resolve(_ components: [TrainingSessionComponent], merge: Bool, repo: Repository) async { await repo.decideTrainingSessionPair(components, merge: merge) await load(repo: repo) diff --git a/StrandTests/TrainingLoadLanesTests.swift b/StrandTests/TrainingLoadLanesTests.swift new file mode 100644 index 0000000000..d1cd61d94e --- /dev/null +++ b/StrandTests/TrainingLoadLanesTests.swift @@ -0,0 +1,140 @@ +import XCTest +import StrandAnalytics +import WhoopStore +@testable import Strand + +/// Pins the lane readings Training Load shows, so the screens that share them can be checked against the +/// exact figures rather than against a second implementation. +final class TrainingLoadLanesTests: XCTestCase { + /// Midday UTC; every fixture day is an offset from it. + static let now = 1_757_937_600 + static let today = AnalyticsEngine.dayString(now, offsetSec: 0) + + static func ts(_ dayOffset: Int, hour: Int = 0) -> Int { now + dayOffset * 86_400 + hour * 3_600 } + + static func workout(_ id: String, day: Int, rpes: [Double?]) -> HevyWorkout { + let sets = rpes.enumerated().map { index, rpe in + HevySet(index: index, type: .normal, weightKg: 80, reps: 6, distanceM: nil, durationS: nil, + rpe: rpe, customMetric: nil) + } + let exercise = HevyExercise(index: 0, title: "Bench Press", templateId: "bench", supersetId: nil, + notes: nil, sets: sets) + return HevyWorkout(id: id, title: "Push", routineId: nil, notes: nil, startTs: ts(day), + endTs: ts(day) + 3_600, updatedAtTs: ts(day) + 3_600, createdAtTs: ts(day), + exercises: [exercise]) + } + + static func cardio(_ id: String, day: Int, hour: Int = 2) -> UnifiedTrainingSession { + let start = ts(day, hour: hour) + let row = WorkoutRow(startTs: start, endTs: start + 3_600, sport: "Running", source: "apple-health", + durationS: 3_600, energyKcal: nil, avgHr: 140, maxHr: 165, strain: 10, + distanceM: 8_000, zonesJSON: nil, notes: nil, steps: nil) + return UnifiedTrainingSession(id: id, kind: .endurance, row: row, + components: [TrainingSessionComponent(id: id, row: row, metadata: nil)], + fusionOrigin: "automatic") + } + + /// Sixty days of history: lifting every third day with a mix of rated and unrated sets, running every + /// second day with one long unpriced run (an unknown day), one duplicate awaiting review, and session + /// ratings including a rated-twice session. + struct Fixture { + let strength: ResolvedStrengthHistory + let sessions: [UnifiedTrainingSession] + let cardio: TrainingCardioLoadResolution + let ratings: [SessionRPEEntry] + } + + static func fixture(days: Int = 60, unpricedDay: Int = -4) -> Fixture { + var workouts: [HevyWorkout] = [] + for day in stride(from: -(days - 1), through: 0, by: 3) { + let heavy = day > -10 + workouts.append(workout("w\(-day)", day: day, + rpes: heavy ? [8, 9, nil, 9, 8] : [7, nil, 8])) + } + var sessions: [UnifiedTrainingSession] = [] + var resolution = TrainingCardioLoadResolution() + for day in stride(from: -(days - 2), through: 0, by: 2) { + let session = cardio("c\(-day)", day: day) + sessions.append(session) + guard day != unpricedDay else { continue } + let trimp = 60 + Double((-day) % 7) * 9 + (day > -8 ? 40 : 0) + resolution.loads[session.id] = TrainingCardioLoad(sessionId: session.id, trimp: trimp, effort: 10, + source: .noopBand, coveredMinutes: 60, + possibleMinutes: 60) + } + let twin = cardio("twin", day: -2, hour: 2) + sessions.append(UnifiedTrainingSession(id: "twin", kind: twin.kind, + row: WorkoutRow(startTs: twin.row.startTs + 300, + endTs: twin.row.endTs + 300, + sport: "Running", source: "noop", + durationS: 3_600, energyKcal: nil, avgHr: 141, + maxHr: 166, strain: 10, distanceM: nil, + zonesJSON: nil, notes: nil, steps: nil), + components: twin.components, fusionOrigin: "automatic")) + resolution.duplicateSessionIds = ["twin"] + + var ratings: [SessionRPEEntry] = [] + for day in stride(from: -20, through: 0, by: 2) where day % 4 == 0 { + ratings.append(SessionRPEEntry(id: "r\(-day)", sessionId: "c\(-day)", startTs: ts(day, hour: 2), + rpe: 6, sport: "Running", ratedAtTs: ts(day, hour: 3))) + } + ratings.append(SessionRPEEntry(id: "r0-again", sessionId: "c0", startTs: ts(0, hour: 2), rpe: 8, + sport: "Running", ratedAtTs: ts(0, hour: 5))) + let strength = ResolvedStrengthHistory(sessions: [], workouts: workouts, templates: [:], + historyAvailableFrom: ts(-(days - 1))) + return Fixture(strength: strength, sessions: sessions, cardio: resolution, ratings: ratings) + } + + static func describe(_ lane: TrainingLoadModel.Lane?) -> String { + guard let lane else { return "nil" } + func f(_ value: Double?) -> String { value.map { String(format: "%.6f", $0) } ?? "nil" } + return [ + "total=\(f(lane.sevenDayTotal))", + "sets=\(lane.sevenDayWorkingSets)", + "ratio=\(f(lane.trend?.ratio))", + "pct=\(f(lane.trend?.percentChange))", + "maturity=\(lane.relative.maturity)", + "band=\(lane.relative.band.map { "\($0)" } ?? "nil")", + "lower=\(lane.isLowerBound)", + "monotony=\(f(lane.distribution?.monotony))", + "strain=\(f(lane.distribution?.strain))", + "wow=\(f(lane.weekOverWeek))", + "measured=\(lane.measuredCount)/\(lane.possibleCount)", + "status=\(lane.status.map { "\($0.band)" } ?? "nil")", + ].joined(separator: " ") + } + + static func describe(_ ratios: [TrainingLoadModel.RatioPoint]) -> String { + func f(_ value: Double?) -> String { value.map { String(format: "%.6f", $0) } ?? "nil" } + return "count=\(ratios.count) " + ratios.suffix(3) + .map { "\($0.day):\(f($0.strength)),\(f($0.cardio))" }.joined(separator: " ") + } + + static func prepared(_ fixture: Fixture) -> TrainingLoadModel.Prepared { + TrainingLoadModel.prepare(strengthHistory: fixture.strength, unified: fixture.sessions, + cardioResolution: fixture.cardio, rpeEntries: fixture.ratings, + dailyRows: [], vo2: [], today: today, now: now, offset: 0) + } + + /// Captured from the computation as it stood before the lanes were shared; a change here is a change + /// to what Training Load shows. + func testTodayReadingsMatchThePinnedOracle() { + let prepared = Self.prepared(Self.fixture()) + XCTAssertEqual(Self.describe(prepared.strength), + "total=7.440000 sets=10 ratio=1.649667 pct=64.966741 maturity=baselineGrowing band=nil lower=false monotony=0.632456 strain=4.705469 wow=36.764706 measured=8/10 status=above") + XCTAssertEqual(Self.describe(prepared.cardio), + "total=372.000000 sets=0 ratio=nil pct=nil maturity=baselineGrowing band=nil lower=true monotony=nil strain=nil wow=nil measured=3/4 status=nil") + XCTAssertEqual(Self.describe(prepared.session), + "total=840.000000 sets=0 ratio=nil pct=nil maturity=earlyEstimate band=nil lower=true monotony=nil strain=nil wow=nil measured=2/7 status=nil") + XCTAssertEqual(Self.describe(prepared.ratios), + "count=56 2025-09-13:2.735802,1.306513 2025-09-14:1.500000,1.042146 2025-09-15:1.649667,1.425287") + XCTAssertNil(prepared.provisionalStrengthRing) + XCTAssertTrue(prepared.cardioMeasured) + } + + func testAnUnknownDayOutsideTheWindowLeavesTheCardioComparisonIntact() { + let prepared = Self.prepared(Self.fixture(unpricedDay: -40)) + XCTAssertEqual(Self.describe(prepared.cardio), + "total=508.000000 sets=0 ratio=1.668309 pct=66.830870 maturity=baselineGrowing band=nil lower=false monotony=1.122291 strain=570.123789 wow=94.636015 measured=4/4 status=above") + } +} From edc8c7f54524e7200a795ff14cdb9812bb31bf15 Mon Sep 17 00:00:00 2001 From: DX23876 <176692557+DX23876@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:05:40 +0200 Subject: [PATCH 2/2] refactor(training): read Cardio and Strength load from Training Load's lanes The Cardio and Strength screens computed "load vs your usual" their own way: Cardio could fall back to non-TRIMP effort and knew no unknown days, and both compared only through today. Tapping from Training Load to either screen could therefore show a different percentage for the same week. TrainingLoadLanes now holds the one strength and cardio lane computation, read through any day. Training Load reads it through today. Cardio and Strength read it through the selected week's Sunday, over their history window plus the 84-day lookback a reading needs, and their load tiles and coach context use it. Analysis migration required: no --- Strand/Screens/CardioModel.swift | 47 ++++--- Strand/Screens/CardioView.swift | 4 +- Strand/Screens/StrengthModel.swift | 29 ++++- Strand/Screens/StrengthView.swift | 4 +- Strand/Screens/TrainingLoadLanes.swift | 154 +++++++++++++++++++++++ Strand/Screens/TrainingLoadView.swift | 120 +++--------------- StrandTests/TrainingLoadLanesTests.swift | 56 ++++++++- 7 files changed, 281 insertions(+), 133 deletions(-) create mode 100644 Strand/Screens/TrainingLoadLanes.swift diff --git a/Strand/Screens/CardioModel.swift b/Strand/Screens/CardioModel.swift index 0697d9ce10..729d3b1b61 100644 --- a/Strand/Screens/CardioModel.swift +++ b/Strand/Screens/CardioModel.swift @@ -58,7 +58,9 @@ final class CardioModel: ObservableObject { distanceM: 0, energyKcal: 0, effort: nil, sessionsWithDistance: 0, bySport: []) @Published private(set) var typicalMinutes: ClosedRange? - @Published private(set) var load: LoadTrend? + /// The selected week's cardio lane, read exactly as Training Load reads it. + @Published private(set) var lane: TrainingLoadModel.Lane? + @Published private(set) var laneRatios: [TrainingLoadModel.RatioPoint] = [] @Published private(set) var weekCharge: Double? /// The displayed week's time in each heart-rate zone. Nil when no zone set is known yet, or when /// nothing that week carried a trace complete enough to bin. @@ -76,6 +78,11 @@ final class CardioModel: ObservableObject { private var fusedVisible: [UnifiedTrainingSession] = [] /// Sessions another record already described, so the zone split counts those minutes once. private var duplicateSessionIds: Set = [] + /// The lane reads every training session, strength included, over the history window plus the + /// lookback a reading needs, so the oldest selectable week still compares like Training Load does. + private var laneSessions: [UnifiedTrainingSession] = [] + private var laneResolution = TrainingCardioLoadResolution() + private var laneSeries: TrainingLoadLanes.CardioSeries? // The selected sport @Published private(set) var sportHistory: [CardioSessionMetrics] = [] @@ -100,7 +107,8 @@ final class CardioModel: ObservableObject { private struct WeekBundle: Sendable { let week: CardioWeekSummary let typical: ClosedRange? - let load: LoadTrend? + let lane: TrainingLoadModel.Lane? + let laneRatios: [TrainingLoadModel.RatioPoint] let zones: CardioZoneSplit? } @@ -115,6 +123,8 @@ final class CardioModel: ObservableObject { // differently apart instead of hiding them. let visible = fusion.sessions.filter { $0.kind != .strength } let cardio = await repo.cardioLoads(for: visible) + let laneFusion = await repo.trainingSessions(days: range.days + TrainingLoadLanes.lookbackDays) + let laneResolution = await repo.cardioLoads(for: laneFusion.sessions) fusedVisible = visible duplicateSessionIds = cardio.duplicateSessionIds let rows = visible.map(\.row) @@ -126,6 +136,14 @@ final class CardioModel: ObservableObject { let enduranceStarts = Set(visible.filter { $0.kind == .endurance || $0.kind == .multisport } .map { $0.row.startTs }) + let laneSeries = await Task.detached(priority: .userInitiated) { + TrainingLoadLanes.cardioSeries(sessions: laneFusion.sessions, resolution: laneResolution, + tzOffsetSeconds: offset) + }.value + self.laneSessions = laneFusion.sessions + self.laneResolution = laneResolution + self.laneSeries = laneSeries + let prepared = await Task.detached(priority: .userInitiated) { () -> ([CardioSessionMetrics], [SportChoice]) in let sessions = CardioSession.sessions(rows, tzOffsetSeconds: offset, cardioLoadByStart: loadByStart) @@ -180,18 +198,26 @@ final class CardioModel: ObservableObject { } let anchor = weekAnchorDay - let endDate = weekEndDate let all = sessions let offset = tzOffset + let laneDay = TrainingLoadLanes.readingDay(monday: monday, today: Repository.localDayKey(Date())) + let laneSessions = self.laneSessions + let laneResolution = self.laneResolution + let laneSeries = self.laneSeries // Outside the detached task: binning zones is an async read on the repository, and its result // travels into the bundle as a finished value so the week's cache holds it too. let zones = await weekZoneSplit(repo: repo, monday: monday, sunday: sunday) let bundle = await Task.detached(priority: .userInitiated) { () -> WeekBundle in + let lane = laneSeries.map { + TrainingLoadLanes.cardioLane(sessions: laneSessions, resolution: laneResolution, series: $0, + through: laneDay, tzOffsetSeconds: offset) + } return WeekBundle(week: CardioSession.week(containing: anchor, sessions: all), typical: CardioSession.typicalWeeklyMinutes(all, endingBefore: anchor), - load: CardioSession.cardioLoadTrend(all, asOf: endDate, - tzOffsetSeconds: offset), + lane: lane, + laneRatios: TrainingLoadLanes.ratios(strengthByDay: nil, cardio: laneSeries, + through: laneDay), zones: zones) }.value @@ -219,7 +245,8 @@ final class CardioModel: ObservableObject { private func apply(_ bundle: WeekBundle) { week = bundle.week typicalMinutes = bundle.typical - load = bundle.load + lane = bundle.lane + laneRatios = bundle.laneRatios zoneSplit = bundle.zones } @@ -282,14 +309,6 @@ final class CardioModel: ObservableObject { WeeklyDigestEngine.addDays(Repository.localDayKey(Date()), weekOffset * 7) } - var weekEndDate: Date { - guard let monday = WeeklyDigestEngine.mondayOfWeek(containing: weekAnchorDay), - let sunday = WeightSeries.date(forDay: WeeklyDigestEngine.addDays(monday, 6)) else { - return Date() - } - return min(sunday, Date()) - } - var minWeekOffset: Int { guard let earliest = sessions.map(\.day).min(), let earliestMon = WeeklyDigestEngine.mondayOfWeek(containing: earliest), diff --git a/Strand/Screens/CardioView.swift b/Strand/Screens/CardioView.swift index e57896c355..5408b84d04 100644 --- a/Strand/Screens/CardioView.swift +++ b/Strand/Screens/CardioView.swift @@ -189,7 +189,7 @@ struct CardioView: View { @ViewBuilder private var loadTile: some View { - let load = model.load + let load = model.lane?.trend tile(icon: "chart.bar.fill", label: String(localized: "Load trend"), value: load.map { signedPercent($0.percentChange) } ?? "—", @@ -904,7 +904,7 @@ struct CardioView: View { if model.week.distanceM > 0 { parts.append(String(format: "%.1f km", model.week.distanceM / 1000)) } - if let load = model.load { + if let load = model.lane?.trend { parts.append(String(format: "cardio load %+.0f%% vs own 28-day level", load.percentChange)) } if let sport = model.selectedSport, let line = model.paceTrend { diff --git a/Strand/Screens/StrengthModel.swift b/Strand/Screens/StrengthModel.swift index 44c696fd9f..84d504e052 100644 --- a/Strand/Screens/StrengthModel.swift +++ b/Strand/Screens/StrengthModel.swift @@ -101,7 +101,9 @@ final class StrengthModel: ObservableObject { mondayKey: "", sessionCount: 0, workingSetCount: 0, volumeLoadKg: 0, setsByMuscle: [:], secondarySetsByMuscle: [:], unattributedSetCount: 0) @Published private(set) var typicalBands: [HevyMuscleGroup: ClosedRange] = [:] - @Published private(set) var strengthLoad: LoadTrend? + /// The selected week's strength lane, read exactly as Training Load reads it. + @Published private(set) var lane: TrainingLoadModel.Lane? + @Published private(set) var laneRatios: [TrainingLoadModel.RatioPoint] = [] @Published private(set) var weekStimulus: [HevyMuscleGroup: Double] = [:] @Published private(set) var typicalWeek: [HevyMuscleGroup: Double] = [:] @Published private(set) var weekCharge: Double? @@ -138,6 +140,9 @@ final class StrengthModel: ObservableObject { private var index = MuscleStimulus.SessionStimulusIndex(workouts: [], templates: [:]) /// The wearer's weigh-ins, for pricing bodyweight work at the body that performed it. private var bodyweight = BodyweightTimeline(points: []) + /// Workouts over the history window plus the lane lookback, and their weighted sets per day. + private var laneWorkouts: [HevyWorkout] = [] + private var laneByDay: [String: Double] = [:] /// Week-scoped results, keyed by the week's Monday. The bands and the usual week depend on nothing /// the stepper changes except this key, so stepping back and forward again is free. private var weekCache: [String: WeekBundle] = [:] @@ -147,7 +152,8 @@ final class StrengthModel: ObservableObject { private struct WeekBundle: Sendable { let week: StrengthSession.WeekSummary let bands: [HevyMuscleGroup: ClosedRange] - let load: LoadTrend? + let lane: TrainingLoadModel.Lane + let laneRatios: [TrainingLoadModel.RatioPoint] let stimulus: [HevyMuscleGroup: Double] let typical: [HevyMuscleGroup: Double] let balance: [StrengthBalance.Reading] @@ -168,10 +174,12 @@ final class StrengthModel: ObservableObject { async let historyRead = repo.resolvedStrengthHistory(days: historyDays) async let fusedRead = repo.trainingSessions(days: historyDays) + async let laneHistoryRead = repo.resolvedStrengthHistory(days: historyDays + TrainingLoadLanes.lookbackDays) let history = await historyRead let sessions = history.workouts let catalogue = history.templates let fused = await fusedRead + let laneWorkouts = await laneHistoryRead.workouts let observations = ((try? await store.muscleRecoveryFeedback()) ?? []).compactMap { row in MuscleRecovery.Feeling(rawValue: row.feeling).map { MuscleRecovery.Observation(group: row.muscleGroup, ts: row.ts, feeling: $0) @@ -210,10 +218,13 @@ final class StrengthModel: ObservableObject { ratedShare: index.total().ratedShare, choices: StrengthSession.exerciseFrequency(sessions) .map { ExerciseChoice(templateId: $0.templateId, sessions: $0.sessions) }, - unmapped: history.unmappedExerciseTitles) + unmapped: history.unmappedExerciseTitles, + laneByDay: TrainingLoadLanes.strengthByDay(laneWorkouts, tzOffsetSeconds: offset)) }.value index = prepared.index + self.laneWorkouts = laneWorkouts + laneByDay = prepared.laneByDay bodyweight = BodyweightTimeline(points: weighIns) weekCache.removeAll() @@ -307,6 +318,7 @@ final class StrengthModel: ObservableObject { let ratedShare: Double let choices: [ExerciseChoice] let unmapped: [String] + let laneByDay: [String: Double] } // MARK: - The week @@ -333,7 +345,9 @@ final class StrengthModel: ObservableObject { } let anchor = weekAnchorDay - let endDate = weekEndDate + let laneDay = TrainingLoadLanes.readingDay(monday: monday, today: Repository.localDayKey(Date())) + let laneWorkouts = self.laneWorkouts + let laneByDay = self.laneByDay let sessions = workouts let catalogue = templates let offset = tzOffset @@ -364,7 +378,9 @@ final class StrengthModel: ObservableObject { week: week, bands: StrengthSession.typicalWeeklySets(sessions, templates: catalogue, endingBefore: anchor, tzOffsetSeconds: offset), - load: StrengthSession.strengthLoadTrend(sessions, asOf: endDate, tzOffsetSeconds: offset), + lane: TrainingLoadLanes.strengthLane(workouts: laneWorkouts, byDay: laneByDay, through: laneDay, + tzOffsetSeconds: offset), + laneRatios: TrainingLoadLanes.ratios(strengthByDay: laneByDay, cardio: nil, through: laneDay), stimulus: index.week(containing: anchor).byMuscle, typical: MuscleStimulus.typicalWeeklyStimulus(index: index, endingBefore: anchor), balance: StrengthBalance.readings(setsByMuscle: week.setsByMuscle), @@ -383,7 +399,8 @@ final class StrengthModel: ObservableObject { private func apply(_ bundle: WeekBundle) { week = bundle.week typicalBands = bundle.bands - strengthLoad = bundle.load + lane = bundle.lane + laneRatios = bundle.laneRatios weekStimulus = bundle.stimulus typicalWeek = bundle.typical balance = bundle.balance diff --git a/Strand/Screens/StrengthView.swift b/Strand/Screens/StrengthView.swift index 3dc024e5dd..795ca490d2 100644 --- a/Strand/Screens/StrengthView.swift +++ b/Strand/Screens/StrengthView.swift @@ -552,7 +552,7 @@ struct StrengthView: View { /// team-sport distance research that never covered set counts. The ratio is still there on /// `LoadTrend` for anything that needs it. private var strengthLoadTile: some View { - let load = model.strengthLoad + let load = model.lane?.trend return tile(icon: "chart.bar.fill", label: String(localized: "Strength load"), value: load.map { signedPercent($0.percentChange) } ?? "—", @@ -1821,7 +1821,7 @@ struct StrengthView: View { ?? "\(row.group.label) \(row.sets)" } .joined(separator: ", ") if !muscles.isEmpty { parts.append("working sets — " + muscles) } - if let load = model.strengthLoad { + if let load = model.lane?.trend { // The coach gets the same framing the tile shows: effort-weighted sets against this // person's own recent level, as a percentage. Handing it a bare ratio invited it to // quote 0.8–1.3 bands that were never validated on set counts. diff --git a/Strand/Screens/TrainingLoadLanes.swift b/Strand/Screens/TrainingLoadLanes.swift new file mode 100644 index 0000000000..3f62ca83f7 --- /dev/null +++ b/Strand/Screens/TrainingLoadLanes.swift @@ -0,0 +1,154 @@ +import Foundation +import StrandAnalytics +import WhoopStore + +/// The Strength and Cardio lane readings, computed as of any day. +/// +/// Training Load, Cardio and Strength all show "this lane against your usual", and tapping from one to +/// another must never change the figure. So there is exactly one computation, and the only thing a +/// screen chooses is the day it is read through. +enum TrainingLoadLanes { + /// History a reading needs before its day: the eight-week personal comparison, plus the 28 days of + /// earlier ratings the first of those days weights its unrated sets with. Data older than this does + /// not move a reading. + static let lookbackDays = TrainingLoad.personalBaselineWeeks * TrainingLoad.recentWindow + + TrainingLoad.baselineWindow + + struct CardioSeries: Sendable { + let byDay: [String: Double] + /// Days that held training the data could not price. They leave both comparison windows rather + /// than counting as rest, so a gap in the measurement is never reported as a drop in training. + let unknownDays: Set + let measured: Bool + } + + static func strengthByDay(_ workouts: [HevyWorkout], tzOffsetSeconds: Int) -> [String: Double] { + StrengthSession.weightedSetsByDay(workouts, tzOffsetSeconds: tzOffsetSeconds) + } + + static func cardioSeries(sessions: [UnifiedTrainingSession], resolution: TrainingCardioLoadResolution, + tzOffsetSeconds: Int) -> CardioSeries { + let series = TrainingLoadModel.cardioDailyLoad(sessions: sessions, loads: resolution.loads, + duplicates: resolution.duplicateSessionIds, + tzOffsetSeconds: tzOffsetSeconds) + return CardioSeries(byDay: series.byDay, unknownDays: series.unknownDays, measured: series.measured) + } + + static func strengthLane(workouts: [HevyWorkout], byDay: [String: Double], through day: String, + tzOffsetSeconds: Int) -> TrainingLoadModel.Lane { + let recent = inLastSeven(workouts, through: day, tzOffsetSeconds: tzOffsetSeconds) { $0.startTs } + let pooled = StrengthSession.strengthLoad(recent) + let relative = TrainingLoad.relativeLoad(dailyByDay: byDay, through: day) + return TrainingLoadModel.Lane( + sevenDayTotal: lastSeven(byDay, through: day), + sevenDayWorkingSets: recent.flatMap { $0.exercises.flatMap(\.workingSets) }.count, + trend: relative.trend, + relative: relative, + isLowerBound: false, + distribution: TrainingLoad.distribution(dailyByDay: byDay, through: day), + weekOverWeek: TrainingLoad.weekOverWeek(dailyByDay: byDay, through: day), + measuredCount: pooled.ratedSets, + possibleCount: pooled.workingSets, + status: relativeStatus(relative)) + } + + static func cardioLane(sessions: [UnifiedTrainingSession], resolution: TrainingCardioLoadResolution, + series: CardioSeries, through day: String, + tzOffsetSeconds: Int) -> TrainingLoadModel.Lane { + // A session skipped because another one already priced the same minutes is NOT a session with + // missing heart rate, so it must not widen the coverage denominator. + let recent = inLastSeven(sessions, through: day, tzOffsetSeconds: tzOffsetSeconds) { $0.row.startTs } + .filter { + ($0.row.endTs - $0.row.startTs) >= Repository.cardioLoadMinimumSeconds + && !resolution.duplicateSessionIds.contains($0.id) + } + let relative = TrainingLoad.relativeLoad(dailyByDay: series.byDay, through: day, + unknownDays: series.unknownDays) + return TrainingLoadModel.Lane( + sevenDayTotal: lastSeven(series.byDay, through: day), + sevenDayWorkingSets: 0, + trend: relative.trend, + relative: relative, + isLowerBound: lastSevenContainsUnknown(series.unknownDays, through: day), + distribution: TrainingLoad.distribution(dailyByDay: series.byDay, through: day, + unknownDays: series.unknownDays), + weekOverWeek: TrainingLoad.weekOverWeek(dailyByDay: series.byDay, through: day, + unknownDays: series.unknownDays), + measuredCount: recent.filter { resolution.loads[$0.id] != nil }.count, + possibleCount: recent.count, + status: relativeStatus(relative)) + } + + /// The 56 daily ratios ending at `day`, per lane. A lane the caller did not read stays nil. + static func ratios(strengthByDay: [String: Double]?, cardio: CardioSeries?, + through day: String) -> [TrainingLoadModel.RatioPoint] { + var ratios: [TrainingLoadModel.RatioPoint] = [] + var ratioDay = WeeklyDigestEngine.addDays(day, -55) + for _ in 0..<56 { + ratios.append(TrainingLoadModel.RatioPoint( + day: ratioDay, + strength: strengthByDay.flatMap { TrainingLoad.trend(dailyByDay: $0, through: ratioDay)?.ratio }, + cardio: cardio.flatMap { + TrainingLoad.trend(dailyByDay: $0.byDay, through: ratioDay, unknownDays: $0.unknownDays)?.ratio + })) + ratioDay = WeeklyDigestEngine.addDays(ratioDay, 1) + } + return ratios + } + + /// The day a week is read through: its Sunday, or today while the week is still running. + static func readingDay(monday: String, today: String) -> String { + min(WeeklyDigestEngine.addDays(monday, 6), today) + } + + static func inLastSeven(_ items: [T], through day: String, tzOffsetSeconds: Int, + start: (T) -> Int) -> [T] { + let cutoff = WeeklyDigestEngine.addDays(day, -6) + return items.filter { + let itemDay = AnalyticsEngine.dayString(start($0), offsetSec: tzOffsetSeconds) + return itemDay >= cutoff && itemDay <= day + } + } + + static func lastSeven(_ values: [String: Double], through day: String) -> Double { + var total = 0.0 + var cursor = day + for _ in 0..<7 { + total += values[cursor] ?? 0 + cursor = WeeklyDigestEngine.addDays(cursor, -1) + } + return total + } + + static func lastSevenContainsUnknown(_ unknownDays: Set, through day: String) -> Bool { + var cursor = day + for _ in 0..<7 { + if unknownDays.contains(cursor) { return true } + cursor = WeeklyDigestEngine.addDays(cursor, -1) + } + return false + } + + /// Adapts the neutral relative-load reading to the existing ring renderer. The legacy case names + /// are not presented to the wearer; `TrainingStatusVisuals` labels these as relative-load bands. + static func relativeStatus(_ reading: RelativeLoadReading) -> LaneStatus? { + guard let trend = reading.trend else { return nil } + let relativeBand: RelativeLoadBand = reading.band ?? { + if trend.percentChange < -15 { return .below } + if trend.percentChange <= 15 { return .usual } + if trend.percentChange <= 30 { return .higher } + return .muchHigher + }() + let legacyStatus: TrainingStatus + let legacyBand: TrainingLoadBand + switch relativeBand { + case .below: legacyStatus = .detraining; legacyBand = .below + case .usual: legacyStatus = .maintaining; legacyBand = .maintaining + case .higher: legacyStatus = .productive; legacyBand = .productive + case .muchHigher: legacyStatus = .overreaching; legacyBand = .above + } + return LaneStatus(status: legacyStatus, ratio: trend.ratio, band: legacyBand, + followsRecentHighPhase: false, usedStrengthResponse: false, + usedRecovery: false) + } +} diff --git a/Strand/Screens/TrainingLoadView.swift b/Strand/Screens/TrainingLoadView.swift index 713e7d9f88..ecdce785ad 100644 --- a/Strand/Screens/TrainingLoadView.swift +++ b/Strand/Screens/TrainingLoadView.swift @@ -159,16 +159,10 @@ final class TrainingLoadModel: ObservableObject { offset: Int) -> Prepared { let strengthWorkouts = strengthHistory.workouts let templates = strengthHistory.templates - let cardioLoads = cardioResolution.loads - let strengthByDay = StrengthSession.weightedSetsByDay(strengthWorkouts, - tzOffsetSeconds: offset) - let cardioSeries = Self.cardioDailyLoad(sessions: unified, loads: cardioLoads, - duplicates: cardioResolution.duplicateSessionIds, - tzOffsetSeconds: offset) + let strengthByDay = TrainingLoadLanes.strengthByDay(strengthWorkouts, tzOffsetSeconds: offset) + let cardioSeries = TrainingLoadLanes.cardioSeries(sessions: unified, resolution: cardioResolution, + tzOffsetSeconds: offset) let cardioByDay = cardioSeries.byDay - // Days that held real training the data could not price. They leave BOTH comparison - // windows rather than counting as rest, so a gap in our measurement is never reported as - // a drop in the wearer's training. let cardioUnknown = cardioSeries.unknownDays var durationByStart: [Int: Double] = [:] @@ -216,19 +210,6 @@ final class TrainingLoadModel: ObservableObject { }) let cutoff = WeeklyDigestEngine.addDays(today, -6) - let recentStrength = strengthWorkouts.filter { - let day = AnalyticsEngine.dayString($0.startTs, offsetSec: offset) - return day >= cutoff && day <= today - } - let pooledStrength = StrengthSession.strengthLoad(recentStrength) - // A session skipped because another one already priced the same minutes is NOT a session - // with missing heart rate, so it must not widen the coverage denominator. - let recentCardio = unified.filter { - let day = AnalyticsEngine.dayString($0.row.startTs, offsetSec: offset) - return day >= cutoff && day <= today - && ($0.row.endTs - $0.row.startTs) >= Repository.cardioLoadMinimumSeconds - && !cardioResolution.duplicateSessionIds.contains($0.id) - } let possible = possibleSessionKeysByDay.filter { $0.key >= cutoff && $0.key <= today } .values.reduce(0) { $0 + $1.count } let measured = ratedSessionKeysByDay.filter { $0.key >= cutoff && $0.key <= today } @@ -240,9 +221,11 @@ final class TrainingLoadModel: ObservableObject { templates: templates, through: today, tzOffsetSeconds: offset) let recovery = TrainingStatusModel.recovery(days: dailyRows, through: today) - let strengthRelative = TrainingLoad.relativeLoad(dailyByDay: strengthByDay, through: today) - let cardioRelative = TrainingLoad.relativeLoad(dailyByDay: cardioByDay, through: today, - unknownDays: cardioUnknown) + let strengthLane = TrainingLoadLanes.strengthLane(workouts: strengthWorkouts, byDay: strengthByDay, + through: today, tzOffsetSeconds: offset) + let cardioLane = TrainingLoadLanes.cardioLane(sessions: unified, resolution: cardioResolution, + series: cardioSeries, through: today, + tzOffsetSeconds: offset) let sessionRelative = TrainingLoad.relativeLoad(dailyByDay: sessionByDay, through: today, unknownDays: sessionUnknown) let history = TrainingStatusModel.weeklyHistory(weeks: 8, through: today, @@ -251,15 +234,7 @@ final class TrainingLoadModel: ObservableObject { cardioUnknownDays: cardioUnknown, workouts: strengthWorkouts, templates: templates, days: dailyRows, tzOffsetSeconds: offset) - var ratios: [RatioPoint] = [] - var ratioDay = WeeklyDigestEngine.addDays(today, -55) - for _ in 0..<56 { - ratios.append(RatioPoint(day: ratioDay, - strength: TrainingLoad.trend(dailyByDay: strengthByDay, through: ratioDay)?.ratio, - cardio: TrainingLoad.trend(dailyByDay: cardioByDay, through: ratioDay, - unknownDays: cardioUnknown)?.ratio)) - ratioDay = WeeklyDigestEngine.addDays(ratioDay, 1) - } + let ratios = TrainingLoadLanes.ratios(strengthByDay: strengthByDay, cardio: cardioSeries, through: today) let vo2max = TrainingStatusModel.vo2maxResponse(readings: vo2, through: today) let strengthAdaptation = TrainingStatusModel.strengthAdaptation(response) let cardiovascularAdaptation = TrainingStatusModel.cardiovascularAdaptation(vo2max) @@ -267,7 +242,7 @@ final class TrainingLoadModel: ObservableObject { cardioDirection: vo2max.direction, recovery: recovery) let provisionalStrengthRing: ProvisionalStrengthRingReading? - if strengthRelative.trend == nil { + if strengthLane.trend == nil { let recentResolved = strengthHistory.sessions.filter { let day = AnalyticsEngine.dayString($0.startTs, offsetSec: offset) return day >= cutoff && day <= today @@ -294,37 +269,13 @@ final class TrainingLoadModel: ObservableObject { } return Prepared( - strength: Lane(sevenDayTotal: Self.lastSeven(strengthByDay, through: today), - sevenDayWorkingSets: recentStrength.flatMap { - $0.exercises.flatMap(\.workingSets) - }.count, - trend: strengthRelative.trend, - relative: strengthRelative, - isLowerBound: false, - distribution: TrainingLoad.distribution(dailyByDay: strengthByDay, through: today), - weekOverWeek: TrainingLoad.weekOverWeek(dailyByDay: strengthByDay, through: today), - measuredCount: pooledStrength.ratedSets, - possibleCount: pooledStrength.workingSets, - status: Self.relativeStatus(strengthRelative)), - cardio: Lane(sevenDayTotal: Self.lastSeven(cardioByDay, through: today), - sevenDayWorkingSets: 0, - trend: cardioRelative.trend, - relative: cardioRelative, - isLowerBound: Self.lastSevenContainsUnknown(cardioUnknown, through: today), - distribution: TrainingLoad.distribution(dailyByDay: cardioByDay, through: today, - unknownDays: cardioUnknown), - weekOverWeek: TrainingLoad.weekOverWeek(dailyByDay: cardioByDay, through: today, - unknownDays: cardioUnknown), - measuredCount: recentCardio.filter { - cardioLoads[$0.id] != nil - }.count, - possibleCount: recentCardio.count, - status: Self.relativeStatus(cardioRelative)), - session: Lane(sevenDayTotal: Self.lastSeven(sessionByDay, through: today), + strength: strengthLane, + cardio: cardioLane, + session: Lane(sevenDayTotal: TrainingLoadLanes.lastSeven(sessionByDay, through: today), sevenDayWorkingSets: 0, trend: sessionRelative.trend, relative: sessionRelative, - isLowerBound: Self.lastSevenContainsUnknown(sessionUnknown, through: today), + isLowerBound: TrainingLoadLanes.lastSevenContainsUnknown(sessionUnknown, through: today), distribution: TrainingLoad.distribution(dailyByDay: sessionByDay, through: today, unknownDays: sessionUnknown), weekOverWeek: TrainingLoad.weekOverWeek(dailyByDay: sessionByDay, through: today, @@ -451,49 +402,6 @@ final class TrainingLoadModel: ObservableObject { return chosen.values.sorted { ($0.startTs, $0.id) < ($1.startTs, $1.id) } } - nonisolated private static func lastSeven(_ values: [String: Double], through day: String) -> Double { - var total = 0.0 - var cursor = day - for _ in 0..<7 { - total += values[cursor] ?? 0 - cursor = WeeklyDigestEngine.addDays(cursor, -1) - } - return total - } - - nonisolated private static func lastSevenContainsUnknown(_ unknownDays: Set, - through day: String) -> Bool { - var cursor = day - for _ in 0..<7 { - if unknownDays.contains(cursor) { return true } - cursor = WeeklyDigestEngine.addDays(cursor, -1) - } - return false - } - - /// Adapts the new neutral relative-load reading to the existing ring renderer. The legacy case names - /// are not presented to the wearer; `TrainingStatusVisuals` labels these as relative-load bands. - nonisolated private static func relativeStatus(_ reading: RelativeLoadReading) -> LaneStatus? { - guard let trend = reading.trend else { return nil } - let relativeBand: RelativeLoadBand = reading.band ?? { - if trend.percentChange < -15 { return .below } - if trend.percentChange <= 15 { return .usual } - if trend.percentChange <= 30 { return .higher } - return .muchHigher - }() - let legacyStatus: TrainingStatus - let legacyBand: TrainingLoadBand - switch relativeBand { - case .below: legacyStatus = .detraining; legacyBand = .below - case .usual: legacyStatus = .maintaining; legacyBand = .maintaining - case .higher: legacyStatus = .productive; legacyBand = .productive - case .muchHigher: legacyStatus = .overreaching; legacyBand = .above - } - return LaneStatus(status: legacyStatus, ratio: trend.ratio, band: legacyBand, - followsRecentHighPhase: false, usedStrengthResponse: false, - usedRecovery: false) - } - /// The VO₂max readings the cardio lane reads. /// /// Apple Watch's measured Cardio Fitness when it has at least four readings in the window — it comes diff --git a/StrandTests/TrainingLoadLanesTests.swift b/StrandTests/TrainingLoadLanesTests.swift index d1cd61d94e..7f236bdcf2 100644 --- a/StrandTests/TrainingLoadLanesTests.swift +++ b/StrandTests/TrainingLoadLanesTests.swift @@ -34,7 +34,7 @@ final class TrainingLoadLanesTests: XCTestCase { fusionOrigin: "automatic") } - /// Sixty days of history: lifting every third day with a mix of rated and unrated sets, running every + /// Training days are counted back from today, so a longer fixture only adds older days. Sixty days of history: lifting every third day with a mix of rated and unrated sets, running every /// second day with one long unpriced run (an unknown day), one duplicate awaiting review, and session /// ratings including a rated-twice session. struct Fixture { @@ -46,14 +46,14 @@ final class TrainingLoadLanesTests: XCTestCase { static func fixture(days: Int = 60, unpricedDay: Int = -4) -> Fixture { var workouts: [HevyWorkout] = [] - for day in stride(from: -(days - 1), through: 0, by: 3) { + for day in stride(from: -2, through: -(days - 1), by: -3).reversed() { let heavy = day > -10 workouts.append(workout("w\(-day)", day: day, rpes: heavy ? [8, 9, nil, 9, 8] : [7, nil, 8])) } var sessions: [UnifiedTrainingSession] = [] var resolution = TrainingCardioLoadResolution() - for day in stride(from: -(days - 2), through: 0, by: 2) { + for day in stride(from: 0, through: -(days - 2), by: -2).reversed() { let session = cardio("c\(-day)", day: day) sessions.append(session) guard day != unpricedDay else { continue } @@ -137,4 +137,54 @@ final class TrainingLoadLanesTests: XCTestCase { XCTAssertEqual(Self.describe(prepared.cardio), "total=508.000000 sets=0 ratio=1.668309 pct=66.830870 maturity=baselineGrowing band=nil lower=false monotony=1.122291 strain=570.123789 wow=94.636015 measured=4/4 status=above") } + + static func strengthLane(_ fixture: Fixture, through day: String) -> TrainingLoadModel.Lane { + let workouts = fixture.strength.workouts + return TrainingLoadLanes.strengthLane(workouts: workouts, + byDay: TrainingLoadLanes.strengthByDay(workouts, tzOffsetSeconds: 0), + through: day, tzOffsetSeconds: 0) + } + + static func cardioLane(_ fixture: Fixture, through day: String) -> TrainingLoadModel.Lane { + let series = TrainingLoadLanes.cardioSeries(sessions: fixture.sessions, resolution: fixture.cardio, + tzOffsetSeconds: 0) + return TrainingLoadLanes.cardioLane(sessions: fixture.sessions, resolution: fixture.cardio, series: series, + through: day, tzOffsetSeconds: 0) + } + + /// Cardio and Strength read a longer window than Training Load. Anything older than the lookback must + /// not move a reading, or the two screens would disagree with it. + func testHistoryBeyondTheLookbackDoesNotMoveTheReading() { + let shortest = Self.fixture(days: TrainingLoadLanes.lookbackDays + 1, unpricedDay: -40) + let long = Self.fixture(days: 200, unpricedDay: -40) + let prepared = Self.prepared(shortest) + XCTAssertEqual(Self.describe(Self.strengthLane(long, through: Self.today)), Self.describe(prepared.strength)) + XCTAssertEqual(Self.describe(Self.cardioLane(long, through: Self.today)), Self.describe(prepared.cardio)) + } + + /// A past week is read through its own Sunday: training after that day must not reach its reading. + func testAPastWeekIgnoresEverythingAfterItsReadingDay() { + let fixture = Self.fixture(days: 120, unpricedDay: -12) + let day = WeeklyDigestEngine.addDays(Self.today, -9) + let endOfDay = Self.ts(-8) - 12 * 3_600 + var truncatedLoads = TrainingCardioLoadResolution() + let sessions = fixture.sessions.filter { $0.row.startTs < endOfDay } + truncatedLoads.loads = fixture.cardio.loads.filter { id, _ in sessions.contains { $0.id == id } } + truncatedLoads.duplicateSessionIds = fixture.cardio.duplicateSessionIds + let truncated = Fixture( + strength: ResolvedStrengthHistory(sessions: [], workouts: fixture.strength.workouts.filter { $0.startTs < endOfDay }, + templates: [:], historyAvailableFrom: nil), + sessions: sessions, cardio: truncatedLoads, ratings: []) + + XCTAssertEqual(Self.describe(Self.strengthLane(fixture, through: day)), + Self.describe(Self.strengthLane(truncated, through: day))) + let cardio = Self.cardioLane(fixture, through: day) + XCTAssertEqual(Self.describe(cardio), Self.describe(Self.cardioLane(truncated, through: day))) + XCTAssertTrue(cardio.isLowerBound, "the unpriced run on day -12 sits inside that week") + } + + func testAWeekIsReadThroughItsSundayOrTodayWhileItRuns() { + XCTAssertEqual(TrainingLoadLanes.readingDay(monday: "2025-09-01", today: "2025-09-15"), "2025-09-07") + XCTAssertEqual(TrainingLoadLanes.readingDay(monday: "2025-09-15", today: "2025-09-17"), "2025-09-17") + } }