From 56e7d92ba44f065667872c996aef563330f291b7 Mon Sep 17 00:00:00 2001 From: DX23876 <176692557+DX23876@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:43:47 +0200 Subject: [PATCH 1/5] feat(design): add strength and cardio lane colour tokens Strength and Cardio both used effortColor, so the two training lanes were indistinguishable. Each lane now owns one identity colour for all seven chart styles in light and dark: electric teal for Strength and violet for Cardio, kept clear of the status and Charge colours so a lane never reads as a warning. The hexes live in LaneColorTable and LaneColorTests pin hue separation and white-text contrast on the deep tones. Analysis migration required: no --- .../Sources/StrandDesign/Palette.swift | 124 +++++++++++ .../StrandDesignTests/LaneColorTests.swift | 209 ++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 Packages/StrandDesign/Tests/StrandDesignTests/LaneColorTests.swift diff --git a/Packages/StrandDesign/Sources/StrandDesign/Palette.swift b/Packages/StrandDesign/Sources/StrandDesign/Palette.swift index 26162d26c9..cf8774d79b 100644 --- a/Packages/StrandDesign/Sources/StrandDesign/Palette.swift +++ b/Packages/StrandDesign/Sources/StrandDesign/Palette.swift @@ -730,6 +730,130 @@ public enum StrandPalette { /// 3-stop gauge ramp: calm → balanced → high. public static var stressGradient: Gradient { Gradient(colors: [stressDeep, stressColor, stressBright]) } + // MARK: - Lane colours — Strength / Cardio (redesign) + // + // Teal for Strength: orange, red and yellow are status colours and green is Charge, and a lane must + // never read as a status. `LaneColorTests` pins the hue and contrast margins. + + /// The raw hex table backing the lane tokens below, keyed by `chartStyle`. + enum LaneColorTable { + /// A light/dark hex pair, kept as raw strings so `LaneColorTests` can parse them directly (a + /// SwiftUI `Color` built from a dynamic provider cannot be read back). + struct Hex { + let light: String + let dark: String + } + + /// The six lane swatches for one chart style, each lane as deep → color → bright. + struct Style { + let strengthDeep: Hex + let strengthColor: Hex + let strengthBright: Hex + let cardioDeep: Hex + let cardioColor: Hex + let cardioBright: Hex + } + + static let signature = Style( + strengthDeep: Hex(light: "#064E53", dark: "#086E74"), + strengthColor: Hex(light: "#0A8F97", dark: "#1ED6E0"), + strengthBright: Hex(light: "#0FB3BC", dark: "#7CEBF0"), + cardioDeep: Hex(light: "#291268", dark: "#3A1A93"), + cardioColor: Hex(light: "#440CDF", dark: "#7D51F6"), + cardioBright: Hex(light: "#622FEE", dark: "#A98CF8") + ) + static let titanium = Style( + strengthDeep: Hex(light: "#064A50", dark: "#0A6A72"), + strengthColor: Hex(light: "#0B8791", dark: "#22C7D6"), + strengthBright: Hex(light: "#12A6B2", dark: "#7FE3EC"), + cardioDeep: Hex(light: "#2E1763", dark: "#41218C"), + cardioColor: Hex(light: "#4B1BBB", dark: "#7A4CE6"), + cardioBright: Hex(light: "#662FE4", dark: "#A788F2") + ) + static let classic = Style( + strengthDeep: Hex(light: "#074A4E", dark: "#0B6B70"), + strengthColor: Hex(light: "#0D858C", dark: "#25C4CC"), + strengthBright: Hex(light: "#14A2AA", dark: "#83E0E5"), + cardioDeep: Hex(light: "#2A1763", dark: "#3C218C"), + cardioColor: Hex(light: "#431BBB", dark: "#734CE6"), + cardioBright: Hex(light: "#5D2FE4", dark: "#A288F2") + ) + static let health = Style( + strengthDeep: Hex(light: "#064C52", dark: "#0A7078"), + strengthColor: Hex(light: "#0A8C96", dark: "#1FD3E0"), + strengthBright: Hex(light: "#0FAEB9", dark: "#7FEAF1"), + cardioDeep: Hex(light: "#211268", dark: "#2E1A93"), + cardioColor: Hex(light: "#2F0CDF", dark: "#6C51F6"), + cardioBright: Hex(light: "#4F2FEE", dark: "#9E8CF8") + ) + static let aurora = Style( + strengthDeep: Hex(light: "#1F4648", dark: "#2E6466"), + strengthColor: Hex(light: "#3E8384", dark: "#5FB3B3"), + strengthBright: Hex(light: "#4F9FA0", dark: "#9ACFCE"), + cardioDeep: Hex(light: "#351F51", dark: "#4D2D76"), + cardioColor: Hex(light: "#5A3091", dark: "#8757C7"), + cardioBright: Hex(light: "#723EB6", dark: "#A883D8") + ) + static let sunset = Style( + strengthDeep: Hex(light: "#074A50", dark: "#0B6C74"), + strengthColor: Hex(light: "#0C8891", dark: "#22C9D4"), + strengthBright: Hex(light: "#13A7B1", dark: "#82E4EA"), + cardioDeep: Hex(light: "#36156A", dark: "#4A1F8F"), + cardioColor: Hex(light: "#5A15C6", dark: "#7F48EE"), + cardioBright: Hex(light: "#7431EA", dark: "#A884F5") + ) + static let forest = Style( + strengthDeep: Hex(light: "#1C4545", dark: "#2A6566"), + strengthColor: Hex(light: "#357B7A", dark: "#4FA9A8"), + strengthBright: Hex(light: "#449897", dark: "#8CC9C8"), + cardioDeep: Hex(light: "#311F51", dark: "#482D76"), + cardioColor: Hex(light: "#543091", dark: "#8057C7"), + cardioBright: Hex(light: "#6A3EB6", dark: "#A283D8") + ) + + static func style(_ chartStyle: ChartStyle) -> Style { + switch chartStyle { + case .signature: return signature + case .titanium: return titanium + case .classic: return classic + case .health: return health + case .aurora: return aurora + case .sunset: return sunset + case .forest: return forest + } + } + } + + /// Strength lane identity: electric teal in every style. + public static var strengthDeep: Color { + let hex = LaneColorTable.style(chartStyle).strengthDeep + return Color(light: hex.light, dark: hex.dark) + } + public static var strengthColor: Color { + let hex = LaneColorTable.style(chartStyle).strengthColor + return Color(light: hex.light, dark: hex.dark) + } + public static var strengthBright: Color { + let hex = LaneColorTable.style(chartStyle).strengthBright + return Color(light: hex.light, dark: hex.dark) + } + public static var strengthGradient: Gradient { Gradient(colors: [strengthDeep, strengthBright]) } + + /// Cardio lane identity: electric violet-indigo in every style. + public static var cardioDeep: Color { + let hex = LaneColorTable.style(chartStyle).cardioDeep + return Color(light: hex.light, dark: hex.dark) + } + public static var cardioColor: Color { + let hex = LaneColorTable.style(chartStyle).cardioColor + return Color(light: hex.light, dark: hex.dark) + } + public static var cardioBright: Color { + let hex = LaneColorTable.style(chartStyle).cardioBright + return Color(light: hex.light, dark: hex.dark) + } + public static var cardioGradient: Gradient { Gradient(colors: [cardioDeep, cardioBright]) } + // MARK: Scenic background (NEW) — detail-screen hero gradient + starfield. /// Radial canvas: lit center → deep edge. Used by `ScenicHeroBackground` (warm-lit on light). public static let scenicCenter = Color(light: "#FBF6EA", dark: "#1C2128") diff --git a/Packages/StrandDesign/Tests/StrandDesignTests/LaneColorTests.swift b/Packages/StrandDesign/Tests/StrandDesignTests/LaneColorTests.swift new file mode 100644 index 0000000000..2d991958dd --- /dev/null +++ b/Packages/StrandDesign/Tests/StrandDesignTests/LaneColorTests.swift @@ -0,0 +1,209 @@ +import XCTest +import SwiftUI +@testable import StrandDesign + +/// Verifies the Strength/Cardio lane colour tokens (`StrandPalette.strengthColor`/`cardioColor` and +/// their deep/bright siblings): each lane must read as one fixed, saturated identity, distinct from +/// the other lane and from the existing status/charge colours, in every chart style and colour scheme. +/// Hue and contrast math is reimplemented here rather than imported from `StrandDesign`, so a bug in +/// the app's own helpers can't hide a palette regression from its own test. +final class LaneColorTests: XCTestCase { + + // MARK: - Pure colour helpers (hex -> sRGB -> HSL hue / WCAG contrast) + + private struct RGB { let r: Double; let g: Double; let b: Double } + + private static func rgb(_ hex: String) -> RGB { + var s = hex + if s.hasPrefix("#") { s.removeFirst() } + let v = UInt32(s, radix: 16) ?? 0 + return RGB(r: Double((v >> 16) & 0xFF) / 255.0, + g: Double((v >> 8) & 0xFF) / 255.0, + b: Double(v & 0xFF) / 255.0) + } + + /// Hue in degrees [0, 360) from an sRGB hex triple, via the standard HSL conversion. + private static func hue(_ hex: String) -> Double { + let c = rgb(hex) + let maxV = max(c.r, c.g, c.b) + let minV = min(c.r, c.g, c.b) + let delta = maxV - minV + guard delta > 0 else { return 0 } + var h: Double + if maxV == c.r { + h = 60 * (((c.g - c.b) / delta).truncatingRemainder(dividingBy: 6)) + } else if maxV == c.g { + h = 60 * (((c.b - c.r) / delta) + 2) + } else { + h = 60 * (((c.r - c.g) / delta) + 4) + } + if h < 0 { h += 360 } + return h + } + + /// Shortest angular distance between two hues, in degrees [0, 180]. + private static func hueDistance(_ a: Double, _ b: Double) -> Double { + let d = abs(a - b).truncatingRemainder(dividingBy: 360) + return min(d, 360 - d) + } + + /// WCAG relative luminance of an sRGB hex colour. + private static func relativeLuminance(_ hex: String) -> Double { + let c = rgb(hex) + func linear(_ v: Double) -> Double { + v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4) + } + return 0.2126 * linear(c.r) + 0.7152 * linear(c.g) + 0.0722 * linear(c.b) + } + + /// WCAG contrast ratio between two sRGB hex colours (always >= 1). + private static func contrastRatio(_ a: String, _ b: String) -> Double { + let l1 = relativeLuminance(a) + let l2 = relativeLuminance(b) + let (hi, lo) = l1 > l2 ? (l1, l2) : (l2, l1) + return (hi + 0.05) / (lo + 0.05) + } + + // MARK: - Reference hex tables + + /// `StrandPalette.onDarkPrimary` (Palette.swift) — fixed/scheme-invariant, copied here as a literal + /// since a `Color` built from a dynamic provider can't be read back to hex. + private static let onDarkPrimary = "#F4F6F8" + + private struct StyleRefs { let light: String; let dark: String } + + // Existing token hex values, copied from `StrandPalette` (Palette.swift) for the hue-separation + // checks below — NOT changed by this change; see the file for their canonical definitions. + private static let statusWarning: [ChartStyle: StyleRefs] = [ + .signature: .init(light: "#C2792E", dark: "#F0A020"), + .titanium: .init(light: "#C2792E", dark: "#F0A020"), + .classic: .init(light: "#CFA528", dark: "#F2C53D"), + .health: .init(light: "#FFCC00", dark: "#FFD60A"), + .aurora: .init(light: "#C9A860", dark: "#EBCB8B"), + .sunset: .init(light: "#E0952E", dark: "#FFB74D"), + .forest: .init(light: "#BC8A3E", dark: "#D8A657"), + ] + private static let statusCritical: [ChartStyle: StyleRefs] = [ + .signature: .init(light: "#C84E1E", dark: "#E0662F"), + .titanium: .init(light: "#C84E1E", dark: "#E0662F"), + .classic: .init(light: "#CB3A2F", dark: "#E5483B"), + .health: .init(light: "#FF3B30", dark: "#FF453A"), + .aurora: .init(light: "#A54650", dark: "#BF616A"), + .sunset: .init(light: "#E03656", dark: "#FF4D6D"), + .forest: .init(light: "#9C3524", dark: "#B5432E"), + ] + private static let statusPositive: [ChartStyle: StyleRefs] = [ + .signature: .init(light: "#1F8A5B", dark: "#03E095"), + .titanium: .init(light: "#1F8A5B", dark: "#03E095"), + .classic: .init(light: "#2E9E4F", dark: "#46B45A"), + .health: .init(light: "#34C759", dark: "#30D158"), + .aurora: .init(light: "#6E9460", dark: "#A3BE8C"), + .sunset: .init(light: "#5F9456", dark: "#86B87A"), + .forest: .init(light: "#3B7345", dark: "#4E8C57"), + ] + private static let chargeColor: [ChartStyle: StyleRefs] = [ + .signature: .init(light: "#0C8F62", dark: "#31E39C"), + .titanium: .init(light: "#0F9D62", dark: "#03E095"), + .classic: .init(light: "#2E9E4F", dark: "#46B45A"), + .health: .init(light: "#34C759", dark: "#30D158"), + .aurora: .init(light: "#6E9460", dark: "#A3BE8C"), + .sunset: .init(light: "#E0AE3E", dark: "#FFD166"), + .forest: .init(light: "#437E4C", dark: "#5A9C63"), + ] + // Soft constraint only (not asserted) — recorded per the design brief's request to note styles + // that land within 18° of it. + private static let effortColor: [ChartStyle: StyleRefs] = [ + .signature: .init(light: "#0A63B8", dark: "#3AA0FF"), + .titanium: .init(light: "#2A78C8", dark: "#4090E0"), + .classic: .init(light: "#3A74C4", dark: "#4A90E2"), + .health: .init(light: "#FF9500", dark: "#FF9F0A"), + .aurora: .init(light: "#5C82A6", dark: "#81A1C1"), + .sunset: .init(light: "#E04E50", dark: "#FF6B6B"), + .forest: .init(light: "#AC7239", dark: "#C58A47"), + ] + + private static let allStyles = ChartStyle.allCases + + private func lane(_ style: ChartStyle) -> StrandPalette.LaneColorTable.Style { + StrandPalette.LaneColorTable.style(style) + } + + // MARK: - 1: Strength vs Cardio hue separation + + func testLaneHuesAreAtLeast60DegreesApart() { + for style in Self.allStyles { + let s = lane(style) + for (mode, strengthHex, cardioHex) in [ + ("light", s.strengthColor.light, s.cardioColor.light), + ("dark", s.strengthColor.dark, s.cardioColor.dark), + ] { + let d = Self.hueDistance(Self.hue(strengthHex), Self.hue(cardioHex)) + XCTAssertGreaterThanOrEqual(d, 60, "\(style)/\(mode): strength/cardio hue distance \(d)") + } + } + } + + // MARK: - 2: separation from statusWarning / statusCritical / statusPositive / chargeColor + + func testLaneHuesAreSeparatedFromStatusAndCharge() { + let refTables: [(String, [ChartStyle: StyleRefs])] = [ + ("warning", Self.statusWarning), ("critical", Self.statusCritical), + ("positive", Self.statusPositive), ("charge", Self.chargeColor), + ] + for style in Self.allStyles { + let s = lane(style) + for (mode, strengthHex, cardioHex) in [ + ("light", s.strengthColor.light, s.cardioColor.light), + ("dark", s.strengthColor.dark, s.cardioColor.dark), + ] { + let strengthHue = Self.hue(strengthHex) + let cardioHue = Self.hue(cardioHex) + for (name, table) in refTables { + guard let ref = table[style] else { continue } + let refHex = mode == "light" ? ref.light : ref.dark + let refHue = Self.hue(refHex) + let ds = Self.hueDistance(strengthHue, refHue) + let dc = Self.hueDistance(cardioHue, refHue) + XCTAssertGreaterThanOrEqual(ds, 18, "\(style)/\(mode): strength vs \(name) hue distance \(ds)") + XCTAssertGreaterThanOrEqual(dc, 18, "\(style)/\(mode): cardio vs \(name) hue distance \(dc)") + } + } + } + } + + /// `effortColor` is NOT a hard constraint — this only records (via `print`, visible with + /// `swift test --verbose`) which styles land within 18° of it, per the design brief. + func testEffortProximityIsRecordedNotEnforced() { + for style in Self.allStyles { + let s = lane(style) + guard let ref = Self.effortColor[style] else { continue } + for (mode, strengthHex, refHex) in [ + ("light", s.strengthColor.light, ref.light), + ("dark", s.strengthColor.dark, ref.dark), + ] { + let d = Self.hueDistance(Self.hue(strengthHex), Self.hue(refHex)) + if d < 18 { + print("LaneColorTests: strengthColor is within 18° of effortColor in " + + "\(style)/\(mode) (\u{394}=\(String(format: "%.1f", d))\u{b0})") + } + } + } + } + + // MARK: - 3: deep tones carry small white text (onDarkPrimary) at >= 4.5:1 + + func testLaneDeepTonesHaveSufficientContrastForWhiteText() { + for style in Self.allStyles { + let s = lane(style) + for (mode, strengthDeepHex, cardioDeepHex) in [ + ("light", s.strengthDeep.light, s.cardioDeep.light), + ("dark", s.strengthDeep.dark, s.cardioDeep.dark), + ] { + let cs = Self.contrastRatio(strengthDeepHex, Self.onDarkPrimary) + let cc = Self.contrastRatio(cardioDeepHex, Self.onDarkPrimary) + XCTAssertGreaterThanOrEqual(cs, 4.5, "\(style)/\(mode): strengthDeep contrast \(cs)") + XCTAssertGreaterThanOrEqual(cc, 4.5, "\(style)/\(mode): cardioDeep contrast \(cc)") + } + } + } +} 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 2/5] 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 3/5] 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") + } } From 5d97dfb292094425ebd5b76dbffdea3c802c05cd Mon Sep 17 00:00:00 2001 From: DX23876 <176692557+DX23876@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:37:35 +0200 Subject: [PATCH 4/5] feat(training): shared design kit for Training Load, Cardio and Strength The three screens get one visual grammar while each keeps its own layout: a lane hero card with status pill, count-up percentage and trend line, a KPI strip, a load-over-time chart with 7D/4W/12W and the usual-week band, summary tiles that open their full card, explainer rows that move method text into sheets, a shared week control and a two-column layout for wide windows. Blocks take finished values and only design tokens. No screen uses them yet. Analysis migration required: no --- Strand/Resources/Localizable.xcstrings | 832 +++++++++++++++++++++++ Strand/Screens/TrainingDesignKit.swift | 765 +++++++++++++++++++++ StrandTests/TrainingDesignKitTests.swift | 65 ++ Tools/translations/de.json | 15 +- Tools/translations/es.json | 15 +- Tools/translations/fr.json | 15 +- Tools/translations/it.json | 15 +- Tools/translations/pl.json | 15 +- Tools/translations/pt-PT.json | 15 +- Tools/translations/ru.json | 15 +- Tools/translations/zh-Hans.json | 15 +- Tools/translations/zh-Hant.json | 15 +- 12 files changed, 1788 insertions(+), 9 deletions(-) create mode 100644 Strand/Screens/TrainingDesignKit.swift create mode 100644 StrandTests/TrainingDesignKitTests.swift diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 613f4b6325..5dd0e62d97 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -435461,6 +435461,838 @@ } } } + }, + "4W" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "4W" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "4W" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "4S" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "4S" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "4S" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "4T" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "4S" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "4Н" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "4周" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "4週" + } + } + } + }, + "12W" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "12W" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "12W" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "12S" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "12S" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "12S" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "12T" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "12S" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "12Н" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "12周" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "12週" + } + } + } + }, + "About usual" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etwa wie üblich" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "About usual" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Como de costumbre" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comme d’habitude" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Come al solito" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jak zwykle" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Como habitualmente" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Как обычно" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "与平常相当" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "與平常相當" + } + } + } + }, + "Above usual" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Über dem Üblichen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Above usual" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Por encima de lo habitual" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Au-dessus de l’habitude" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sopra il solito" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Powyżej zwykłego" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acima do habitual" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выше обычного" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "高于平常" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "高於平常" + } + } + } + }, + "No comparison yet" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noch kein Vergleich" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No comparison yet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aún sin comparación" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pas encore de comparaison" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ancora nessun confronto" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brak porównania" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ainda sem comparação" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сравнения пока нет" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂无对比" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "暫無對比" + } + } + } + }, + "Opens the details" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öffnet die Details" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opens the details" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abre los detalles" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvre les détails" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apre i dettagli" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otwiera szczegóły" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abre os detalhes" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открывает подробности" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开详情" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "打開詳情" + } + } + } + }, + "Partly unmeasured" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Teilweise ungemessen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Partly unmeasured" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medido en parte" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Partiellement non mesuré" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Misurato solo in parte" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Częściowo niezmierzone" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Parcialmente por medir" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Частично не измерено" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "部分未测量" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "部分未測量" + } + } + } + }, + "Provisional" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vorläufig" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Provisional" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Provisional" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Provisoire" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Provvisorio" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wstępnie" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Provisório" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предварительно" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "初步" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "初步" + } + } + } + }, + "Usual high" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Üblich oben" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usual high" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Límite superior habitual" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Haut habituel" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limite alto abituale" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zwykła górna granica" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limite superior habitual" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обычный максимум" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "平常上限" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "平常上限" + } + } + } + }, + "Usual low" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Üblich unten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usual low" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Límite inferior habitual" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bas habituel" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limite basso abituale" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zwykła dolna granica" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limite inferior habitual" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обычный минимум" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "平常下限" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "平常下限" + } + } + } + }, + "Your usual range appears after eight complete weeks." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dein üblicher Bereich erscheint nach acht vollständigen Wochen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your usual range appears after eight complete weeks." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu rango habitual aparece tras ocho semanas completas." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre plage habituelle apparaît après huit semaines complètes." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il tuo intervallo abituale compare dopo otto settimane complete." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twój zwykły zakres pojawi się po ośmiu pełnych tygodniach." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "O teu intervalo habitual aparece após oito semanas completas." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваш обычный диапазон появится после восьми полных недель." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "满八个完整周后显示你的平常范围。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "滿八個完整週後顯示你的平常範圍。" + } + } + } + }, + "Your usual week" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deine übliche Woche" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your usual week" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu semana habitual" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre semaine habituelle" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La tua settimana abituale" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twój zwykły tydzień" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "A tua semana habitual" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваша обычная неделя" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你的平常一周" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "你的平常一週" + } + } + } + }, + "vs. your usual" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "ggü. deinem Üblichen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "vs. your usual" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "frente a lo habitual" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "vs votre habitude" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "rispetto al solito" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wobec zwykłego" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "face ao habitual" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "к вашему обычному" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "对比平常" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "對比平常" + } + } + } } }, "version" : "1.0" diff --git a/Strand/Screens/TrainingDesignKit.swift b/Strand/Screens/TrainingDesignKit.swift new file mode 100644 index 0000000000..6f01c95629 --- /dev/null +++ b/Strand/Screens/TrainingDesignKit.swift @@ -0,0 +1,765 @@ +import SwiftUI +import Charts +import StrandDesign +import StrandAnalytics + +// MARK: - Shared building blocks for Training Load, Cardio and Strength +// +// One visual grammar for three screens that answer different questions. The blocks take finished +// values; each screen decides what goes in them and in which order. + +/// The two training lanes and the identity each carries on every screen. +enum TrainingLane: Sendable { + case strength, cardio + + var color: Color { self == .strength ? StrandPalette.strengthColor : StrandPalette.cardioColor } + var deep: Color { self == .strength ? StrandPalette.strengthDeep : StrandPalette.cardioDeep } + var bright: Color { self == .strength ? StrandPalette.strengthBright : StrandPalette.cardioBright } + var symbol: String { self == .strength ? "figure.strengthtraining.traditional" : "heart.fill" } + var title: String { + self == .strength ? String(localized: "Strength") : String(localized: "Cardio") + } + /// Deep to base, never to bright: white text has to stay readable across the whole fill. + var fill: LinearGradient { + LinearGradient(colors: [deep, color], startPoint: .leading, endPoint: .trailing) + } +} + +// MARK: - Status pill + +/// Where a lane's last seven days sit against the wearer's usual. The words carry the state; the +/// colour only says which lane it belongs to. +enum LoadPillState: Equatable, Sendable { + case below, usual, higher, muchHigher, provisional, noComparison + + static func of(_ lane: TrainingLoadModel.Lane?, provisional: Bool = false) -> LoadPillState { + switch lane?.status?.band { + case .below: return .below + case .maintaining: return .usual + case .productive: return .higher + case .above: return .muchHigher + case nil: return provisional ? .provisional : .noComparison + } + } + + var hasComparison: Bool { self != .provisional && self != .noComparison } + + var label: String { + switch self { + case .below: return String(localized: "Below usual") + case .usual: return String(localized: "About usual") + case .higher: return String(localized: "Above usual") + case .muchHigher: return String(localized: "Well above usual") + case .provisional: return String(localized: "Provisional") + case .noComparison: return String(localized: "No comparison yet") + } + } + + var symbol: String { + switch self { + case .below: return "arrow.down.right" + case .usual: return "equal" + case .higher: return "arrow.up.right" + case .muchHigher: return "chevron.up.2" + case .provisional: return "sparkles" + case .noComparison: return "hourglass" + } + } +} + +struct LoadStatusPill: View { + let lane: TrainingLane + let state: LoadPillState + + var body: some View { + Label(state.label, systemImage: state.symbol) + .font(StrandFont.caption.weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.75) + .foregroundStyle(state.hasComparison ? StrandPalette.onDarkPrimary : StrandPalette.textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background { + if state.hasComparison { + Capsule().fill(lane.fill) + } else { + Capsule().fill(StrandPalette.surfaceInset) + } + } + } +} + +// MARK: - Formatting + +enum LoadFormat { + /// "+18 %", "−7 %", and "0 %" for a change that rounds to nothing, which has no direction. + static func signedPercent(_ value: Double) -> String { + let magnitude = Int(abs(value).rounded()) + guard magnitude > 0 else { return "0 %" } + return "\(value > 0 ? "+" : "−")\(magnitude) %" + } +} + +/// A signed percentage that counts between values instead of jumping. +struct SignedPercentCountUp: View, Animatable { + var value: Double + var animatableData: Double { + get { value } + set { value = newValue } + } + + var body: some View { + Text(verbatim: LoadFormat.signedPercent(value)).monospacedDigit() + } +} + +// MARK: - Hero + +/// A lane-tinted card surface, saturated enough to carry the lane's identity at a glance. +struct LaneHeroSurface: View { + let lane: TrainingLane + @Environment(\.colorScheme) private var scheme + + var body: some View { + let shape = RoundedRectangle(cornerRadius: NoopMetrics.cardRadius, style: .continuous) + // Lighter on a light background: the secondary text on top has to keep its contrast. + let dark = scheme == .dark + ZStack { + shape.fill(StrandPalette.surfaceRaised) + shape.fill(LinearGradient(colors: [lane.deep.opacity(dark ? 0.62 : 0.2), + lane.color.opacity(dark ? 0.22 : 0.08)], + startPoint: .topLeading, endPoint: .bottomTrailing)) + shape.fill(RadialGradient(colors: [lane.bright.opacity(dark ? 0.28 : 0.22), .clear], + center: .topTrailing, startRadius: 0, endRadius: 220)) + } + .overlay(shape.strokeBorder(lane.color.opacity(0.45), lineWidth: 1)) + .shadow(color: lane.color.opacity(0.22), radius: 16, x: 0, y: 8) + } +} + +/// A lane's headline: the change against usual, its state, the measured figure behind it, and what the +/// reading rests on. `compact` fits half the width of a phone. +struct LoadHeroCard: View { + let lane: TrainingLane + var title: String? = nil + let percent: Double? + let state: LoadPillState + var figure: String? = nil + /// Daily ratios, oldest first; gaps are left out. + var trend: [Double] = [] + var coverage: String? = nil + var caveat: String? = nil + var note: String? = nil + var compact = false + + /// Nil until the value first changes, so the card opens on the real figure rather than counting up from zero. + @State private var shownPercent: Double? + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + VStack(alignment: .leading, spacing: compact ? NoopMetrics.space2 : NoopMetrics.space3) { + header + HStack(alignment: .bottom, spacing: NoopMetrics.space3) { + VStack(alignment: .leading, spacing: 2) { + percentText + if percent != nil { + Text("vs. your usual") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + } + } + if !compact, trend.count >= 2 { sparkline } + } + if compact, trend.count >= 2 { sparkline } + if let figure { + Text(figure) + .font(StrandFont.captionNumber) + .foregroundStyle(StrandPalette.textPrimary) + .fixedSize(horizontal: false, vertical: true) + } + if let coverage { + Text(coverage) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + if let caveat { + Label { + Text(caveat).foregroundStyle(StrandPalette.textPrimary) + } icon: { + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(StrandPalette.statusWarning) + } + .font(StrandFont.caption) + .fixedSize(horizontal: false, vertical: true) + } + if let note { + Label(note, systemImage: "info.circle") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(compact ? NoopMetrics.space3 : NoopMetrics.cardPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .background(LaneHeroSurface(lane: lane)) + .accessibilityElement(children: .combine) + .onChangeCompat(of: percent) { newValue in + let target = newValue ?? 0 + if shownPercent == nil { shownPercent = target } + if reduceMotion { shownPercent = target } else { withAnimation(StrandMotion.drawIn) { shownPercent = target } } + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: NoopMetrics.space2) { + HStack(spacing: NoopMetrics.space2) { + StatusBadge(symbol: lane.symbol, color: lane.color, size: compact ? 24 : 28) + Text(title ?? lane.title) + .font(StrandFont.subhead.weight(.semibold)) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(1) + Spacer(minLength: NoopMetrics.space1) + if !compact { LoadStatusPill(lane: lane, state: state) } + } + if compact { LoadStatusPill(lane: lane, state: state) } + } + } + + @ViewBuilder private var percentText: some View { + if percent != nil { + SignedPercentCountUp(value: shownPercent ?? percent ?? 0) + .font(StrandFont.number(compact ? 30 : 42, weight: .bold)) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.6) + .accessibilityLabel(Text(verbatim: LoadFormat.signedPercent(percent ?? 0))) + } else { + Text(verbatim: "—") + .font(StrandFont.number(compact ? 30 : 42, weight: .bold)) + .foregroundStyle(StrandPalette.textTertiary) + } + } + + private var sparkline: some View { + Sparkline(values: trend, gradient: Gradient(colors: [lane.deep, lane.bright]), + lineWidth: 2.5, showsArea: true, showsHead: true, showsHover: false) + .frame(height: compact ? 34 : 48) + .frame(maxWidth: .infinity) + .accessibilityHidden(true) + } +} + +// MARK: - KPI strip + +struct KPIItem: Identifiable { + let id: String + let icon: String + let value: String + let label: String + var caption: String? = nil + var info: (() -> Void)? = nil +} + +/// A row of a week's key figures. Up to four sit in one row; more wrap into rows of three. +struct KPIStrip: View { + let lane: TrainingLane + let items: [KPIItem] + + var body: some View { + let columns = items.count <= 4 ? max(items.count, 1) : 3 + NoopCard(padding: NoopMetrics.space3) { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: NoopMetrics.space2), count: columns), + alignment: .leading, spacing: NoopMetrics.space3) { + ForEach(items) { item in cell(item) } + } + } + } + + private func cell(_ item: KPIItem) -> some View { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 0) { + ZStack { + Circle().fill(lane.color.opacity(0.2)) + Image(systemName: item.icon) + .font(StrandFont.rounded(11, weight: .semibold)) + .foregroundStyle(lane.color) + } + .frame(width: 24, height: 24) + .accessibilityHidden(true) + Spacer(minLength: 0) + if let info = item.info { + Button(action: info) { + Image(systemName: "info.circle") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("What this means")) + } + } + Text(item.value) + .font(StrandFont.number(20, weight: .bold)) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.55) + Text(item.label) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(item.caption ?? " ") + .font(StrandFont.caption) + .foregroundStyle(lane.bright) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(verbatim: "\(item.label): \(item.value)\(item.caption.map { ", " + $0 } ?? "")")) + } +} + +// MARK: - Load over time + +enum LoadHistorySpan: String, CaseIterable, Identifiable, Sendable { + case week, fourWeeks, twelveWeeks + var id: String { rawValue } + var label: String { + switch self { + case .week: return String(localized: "7D") + case .fourWeeks: return String(localized: "4W") + case .twelveWeeks: return String(localized: "12W") + } + } +} + +/// The bars a load chart draws, worked out without SwiftUI so the windows can be tested. +enum LoadHistoryBuckets { + struct Bar: Identifiable, Equatable, Sendable { + /// First day the bar covers. + let start: String + /// Known load in the bar; nil for a day that has not happened yet. + let value: Double? + /// Part of the bar is training the data could not price, so the value is a lower bound. + let containsUnknown: Bool + /// The day or week the screen is reading. + let isSelected: Bool + var id: String { start } + } + + /// `.week`: the seven days of the week containing `readingDay`. The other spans: whole Monday weeks, + /// ending with that week, summed through `readingDay` for the week still running. + static func bars(byDay: [String: Double], unknownDays: Set, span: LoadHistorySpan, + readingDay: String) -> [Bar] { + guard let monday = WeeklyDigestEngine.mondayOfWeek(containing: readingDay) else { return [] } + switch span { + case .week: + return (0..<7).map { offset in + let day = WeeklyDigestEngine.addDays(monday, offset) + return Bar(start: day, value: day > readingDay ? nil : (byDay[day] ?? 0), + containsUnknown: unknownDays.contains(day), isSelected: day == readingDay) + } + case .fourWeeks, .twelveWeeks: + let weeks = span == .fourWeeks ? 4 : 12 + return (0.. = [] + let readingDay: String + var usualWeek: ClosedRange? = nil + + @State private var span: LoadHistorySpan = .fourWeeks + + var body: some View { + let bars = LoadHistoryBuckets.bars(byDay: byDay, unknownDays: unknownDays, span: span, + readingDay: readingDay) + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.space3) { + HStack(alignment: .center) { + Text(title) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Spacer(minLength: NoopMetrics.space2) + SegmentedPillControl(LoadHistorySpan.allCases, selection: $span) { $0.label } + } + chart(bars) + .frame(height: 170) + legend(bars) + } + } + } + + @ViewBuilder private func chart(_ bars: [LoadHistoryBuckets.Bar]) -> some View { + let band = span == .week ? nil : usualWeek + Chart { + if let band { + RectangleMark(yStart: .value("Usual low", band.lowerBound), + yEnd: .value("Usual high", band.upperBound)) + .foregroundStyle(lane.color.opacity(0.14)) + } + ForEach(bars) { bar in + if let value = bar.value { + BarMark(x: .value("Period", bar.start), y: .value(unit, value), width: .ratio(0.62)) + .foregroundStyle(bar.isSelected + ? AnyShapeStyle(LinearGradient(colors: [lane.bright, lane.deep], + startPoint: .top, endPoint: .bottom)) + : AnyShapeStyle(lane.color.opacity(bar.containsUnknown ? 0.3 : 0.55))) + .cornerRadius(5) + } + } + } + .chartXScale(domain: bars.map(\.start)) + .chartYAxis { + AxisMarks(position: .leading) { _ in + AxisGridLine().foregroundStyle(StrandPalette.hairline) + AxisValueLabel().font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + } + } + .chartXAxis { + AxisMarks { value in + AxisValueLabel { + if let start = value.as(String.self) { + Text(verbatim: axisLabel(start)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + } + } + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(verbatim: accessibilitySummary(bars))) + } + + @ViewBuilder private func legend(_ bars: [LoadHistoryBuckets.Bar]) -> some View { + HStack(spacing: NoopMetrics.space4) { + legendDot(lane.color, title) + if span != .week { + if usualWeek != nil { + legendDot(lane.color.opacity(0.3), String(localized: "Your usual week")) + } else { + Text("Your usual range appears after eight complete weeks.") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + if bars.contains(where: \.containsUnknown) { + legendDot(lane.color.opacity(0.3), String(localized: "Partly unmeasured")) + } + } + } + + private func legendDot(_ color: Color, _ text: String) -> some View { + HStack(spacing: 5) { + Circle().fill(color).frame(width: 8, height: 8) + Text(text).font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary).lineLimit(1) + } + } + + private func axisLabel(_ start: String) -> String { + guard let date = WeightSeries.date(forDay: start) else { return start } + if span == .week { return date.formatted(.dateTime.weekday(.abbreviated)) } + return date.formatted(.dateTime.day().month(.defaultDigits)) + } + + private func accessibilitySummary(_ bars: [LoadHistoryBuckets.Bar]) -> String { + bars.compactMap { bar in + bar.value.map { "\(axisLabel(bar.start)): \(Int($0.rounded())) \(unit)" } + }.joined(separator: ", ") + } +} + +// MARK: - Summary tile + +/// A compact fact that opens its full card. The mini content shows enough to decide whether to open it. +struct SummaryTile: View { + let symbol: String + let tint: Color + let title: String + let headline: String + var detail: String? = nil + let action: () -> Void + @ViewBuilder var mini: () -> Mini + + var body: some View { + Button(action: action) { + VStack(alignment: .leading, spacing: NoopMetrics.space2) { + HStack(spacing: NoopMetrics.space2) { + StatusBadge(symbol: symbol, color: tint, size: 26) + Text(title) + .font(StrandFont.caption.weight(.semibold)) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(1) + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(StrandFont.caption.weight(.semibold)) + .foregroundStyle(StrandPalette.textTertiary) + } + Text(headline) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(2) + .minimumScaleFactor(0.8) + if let detail { + Text(detail) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(2) + } + Spacer(minLength: 0) + mini() + } + .padding(NoopMetrics.space3) + .frame(maxWidth: .infinity, minHeight: 112, alignment: .topLeading) + .background(FrostedCardSurface(tint: tint, cornerRadius: NoopMetrics.groupedRadius)) + .contentShape(RoundedRectangle(cornerRadius: NoopMetrics.groupedRadius, style: .continuous)) + } + .buttonStyle(.plain) + .strandPressable(cornerRadius: NoopMetrics.groupedRadius) + .accessibilityElement(children: .combine) + .accessibilityHint(Text("Opens the details")) + } +} + +// MARK: - Explainers + +struct ExplainerItem: Identifiable { + let id: String + let symbol: String + let title: String + let subtitle: String + let content: () -> AnyView + + init(id: String, symbol: String, title: String, subtitle: String, text: String) { + self.id = id; self.symbol = symbol; self.title = title; self.subtitle = subtitle + self.content = { + AnyView(Text(text) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true)) + } + } + + init(id: String, symbol: String, title: String, subtitle: String, + @ViewBuilder content: @escaping () -> Content) { + self.id = id; self.symbol = symbol; self.title = title; self.subtitle = subtitle + self.content = { AnyView(content()) } + } +} + +/// How the screen's figures are made, one tap away instead of spelled out between the figures. +struct ExplainerRows: View { + var header: String = String(localized: "How it works") + let items: [ExplainerItem] + @State private var open: String? + + var body: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + Text(header) + .font(StrandFont.title2) + .foregroundStyle(StrandPalette.textPrimary) + NoopCard(padding: 0) { + VStack(spacing: 0) { + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + if index > 0 { Divider().overlay(StrandPalette.hairline).padding(.leading, 56) } + Button { open = item.id } label: { row(item) } + .buttonStyle(.plain) + } + } + } + } + .sheet(item: Binding(get: { open.flatMap { id in items.first { $0.id == id } }.map(SheetTarget.init) }, + set: { open = $0?.id })) { target in + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: NoopMetrics.space3) { + Text(target.item.title) + .font(StrandFont.title2) + .foregroundStyle(StrandPalette.textPrimary) + target.item.content() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(NoopMetrics.screenPadding) + } + .background(StrandPalette.surfaceBase.ignoresSafeArea()) + .toolbar { + ToolbarItem(placement: .confirmationAction) { Button("Done") { open = nil } } + } + } + } + } + + private struct SheetTarget: Identifiable { + let item: ExplainerItem + var id: String { item.id } + } + + private func row(_ item: ExplainerItem) -> some View { + HStack(spacing: NoopMetrics.space3) { + StatusBadge(symbol: item.symbol, color: StrandPalette.metricCyan, size: 32) + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .font(StrandFont.subhead.weight(.semibold)) + .foregroundStyle(StrandPalette.textPrimary) + Text(item.subtitle) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(2) + } + Spacer(minLength: NoopMetrics.space2) + Image(systemName: "chevron.right") + .font(StrandFont.caption.weight(.semibold)) + .foregroundStyle(StrandPalette.textTertiary) + } + .padding(.horizontal, NoopMetrics.space3) + .padding(.vertical, NoopMetrics.space3) + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + } +} + +// MARK: - Week control + +/// The week stepper and history window shared by Cardio and Strength. +struct TrainingWeekControl: View { + let overline: String + let rangeText: String + let canGoBack: Bool + let canGoForward: Bool + let step: (Int) -> Void + let ranges: [Range] + @Binding var selectedRange: Range + let rangeLabel: (Range) -> String + + var body: some View { + HStack(alignment: .center, spacing: NoopMetrics.space2) { + VStack(alignment: .leading, spacing: 2) { + Text(overline).strandOverline() + Text(rangeText) + .font(StrandFont.title2) + .foregroundStyle(StrandPalette.textPrimary) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.7) + } + Spacer(minLength: NoopMetrics.space2) + Menu { + ForEach(ranges) { range in + Button { + selectedRange = range + } label: { + if range == selectedRange { Label(rangeLabel(range), systemImage: "checkmark") } + else { Text(rangeLabel(range)) } + } + } + } label: { + Label(rangeLabel(selectedRange), systemImage: "calendar") + .font(StrandFont.caption.weight(.semibold)) + .foregroundStyle(StrandPalette.textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(StrandPalette.surfaceInset, in: Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("History window")) + stepButton("chevron.left", enabled: canGoBack, delta: -1, label: String(localized: "Previous week")) + stepButton("chevron.right", enabled: canGoForward, delta: 1, label: String(localized: "Next week")) + } + } + + private func stepButton(_ symbol: String, enabled: Bool, delta: Int, label: String) -> some View { + Button { step(delta) } label: { + Image(systemName: symbol) + .font(StrandFont.caption.weight(.bold)) + .frame(width: 30, height: 30) + .background(StrandPalette.surfaceInset, in: Circle()) + } + .buttonStyle(.plain) + .foregroundStyle(enabled ? StrandPalette.accent : StrandPalette.textTertiary) + .disabled(!enabled) + .accessibilityLabel(Text(label)) + } +} + +// MARK: - Layout + +/// Side by side where there is room for both, stacked on a phone. +struct AdaptiveTwoColumn: View { + var minimumWidth: CGFloat = 700 + @ViewBuilder let leading: () -> Leading + @ViewBuilder let trailing: () -> Trailing + + var body: some View { + ViewThatFits(in: .horizontal) { + HStack(alignment: .top, spacing: NoopMetrics.gap) { + leading().frame(maxWidth: .infinity) + trailing().frame(maxWidth: .infinity) + } + .frame(minWidth: minimumWidth) + VStack(spacing: NoopMetrics.gap) { + leading() + trailing() + } + } + } +} + +#if DEBUG +#Preview("Training design kit") { + ScrollView { + VStack(spacing: NoopMetrics.gap) { + HStack(spacing: NoopMetrics.gap) { + LoadHeroCard(lane: .strength, percent: 58, state: .muchHigher, + figure: "10 working sets · 7.4 weighted", trend: [0.8, 1.0, 1.2, 1.1, 1.5, 1.6], + coverage: "8 of 10 sets rated", compact: true) + LoadHeroCard(lane: .cardio, percent: nil, state: .noComparison, + figure: "at least 372 TRIMP", coverage: "3 of 4 sessions complete", + caveat: "One session has no usable heart rate", compact: true) + } + LoadHeroCard(lane: .cardio, percent: -12, state: .usual, figure: "508 TRIMP", + trend: [1.2, 1.1, 0.9, 1.0, 0.95, 0.88], coverage: "All 4 sessions measured") + KPIStrip(lane: .cardio, items: [ + KPIItem(id: "s", icon: "figure.run", value: "5", label: "Sessions"), + KPIItem(id: "t", icon: "clock.fill", value: "5h 32m", label: "Moving time", caption: "usual 3–4h"), + KPIItem(id: "d", icon: "point.topleft.down.to.point.bottomright.curvepath", value: "41 km", label: "Distance"), + KPIItem(id: "k", icon: "flame.fill", value: "2,480", label: "kcal"), + ]) + LoadHistoryChart(lane: .strength, title: "Strength load", unit: "sets", + byDay: ["2025-09-01": 4, "2025-09-03": 5, "2025-09-10": 7, "2025-09-15": 6], + readingDay: "2025-09-17", usualWeek: 8...14) + HStack(spacing: NoopMetrics.gap) { + SummaryTile(symbol: "arrow.up.right", tint: StrandPalette.statusPositive, title: "Adaptation", + headline: "Productive development", action: {}) { EmptyView() } + SummaryTile(symbol: "moon.zzz.fill", tint: StrandPalette.metricCyan, title: "Recovery", + headline: "Holding", detail: "1 of 7 nights flagged", action: {}) { EmptyView() } + } + ExplainerRows(items: [ + ExplainerItem(id: "a", symbol: "function", title: "How it is calculated", + subtitle: "TRIMP and your usual range", text: "Explanation."), + ]) + } + .padding() + } + .background(StrandPalette.surfaceBase) +} +#endif diff --git a/StrandTests/TrainingDesignKitTests.swift b/StrandTests/TrainingDesignKitTests.swift new file mode 100644 index 0000000000..d39f299453 --- /dev/null +++ b/StrandTests/TrainingDesignKitTests.swift @@ -0,0 +1,65 @@ +import XCTest +import StrandAnalytics +@testable import Strand + +final class TrainingDesignKitTests: XCTestCase { + private let byDay: [String: Double] = [ + "2025-08-25": 3, "2025-09-01": 4, "2025-09-03": 5, "2025-09-10": 7, "2025-09-15": 6, "2025-09-17": 2, + "2025-09-19": 9, + ] + + func testTheWeekSpanShowsMondayToSundayAndLeavesDaysAheadEmpty() { + let bars = LoadHistoryBuckets.bars(byDay: byDay, unknownDays: ["2025-09-16"], span: .week, + readingDay: "2025-09-17") + XCTAssertEqual(bars.map(\.start), ["2025-09-15", "2025-09-16", "2025-09-17", "2025-09-18", + "2025-09-19", "2025-09-20", "2025-09-21"]) + XCTAssertEqual(bars.map(\.value), [6, 0, 2, nil, nil, nil, nil]) + XCTAssertEqual(bars.map(\.containsUnknown), [false, true, false, false, false, false, false]) + XCTAssertEqual(bars.filter(\.isSelected).map(\.start), ["2025-09-17"]) + } + + func testWeeklySpansEndWithTheReadWeekAndStopAtTheReadingDay() { + let bars = LoadHistoryBuckets.bars(byDay: byDay, unknownDays: ["2025-09-02"], span: .fourWeeks, + readingDay: "2025-09-17") + XCTAssertEqual(bars.map(\.start), ["2025-08-25", "2025-09-01", "2025-09-08", "2025-09-15"]) + XCTAssertEqual(bars.map(\.value), [3, 9, 7, 8], "the 19th is after the reading day") + XCTAssertEqual(bars.map(\.containsUnknown), [false, true, false, false]) + XCTAssertEqual(bars.map(\.isSelected), [false, false, false, true]) + XCTAssertEqual(LoadHistoryBuckets.bars(byDay: byDay, unknownDays: [], span: .twelveWeeks, + readingDay: "2025-09-17").count, 12) + } + + func testAPastWeekIsReadThroughItsSunday() { + let bars = LoadHistoryBuckets.bars(byDay: byDay, unknownDays: [], span: .week, readingDay: "2025-09-07") + XCTAssertEqual(bars.first?.start, "2025-09-01") + XCTAssertEqual(bars.last?.isSelected, true) + XCTAssertFalse(bars.contains { $0.value == nil }) + } + + func testThePillFollowsTheLaneBandAndSaysWhenThereIsNoComparison() { + func lane(_ band: TrainingLoadBand?) -> TrainingLoadModel.Lane { + let status = band.map { + LaneStatus(status: .maintaining, ratio: 1, band: $0, followsRecentHighPhase: false, + usedStrengthResponse: false, usedRecovery: false) + } + return TrainingLoadModel.Lane(sevenDayTotal: 0, sevenDayWorkingSets: 0, trend: nil, + relative: TrainingLoad.relativeLoad(daily: []), isLowerBound: false, + distribution: nil, weekOverWeek: nil, measuredCount: 0, + possibleCount: 0, status: status) + } + XCTAssertEqual(LoadPillState.of(lane(.below)), .below) + XCTAssertEqual(LoadPillState.of(lane(.maintaining)), .usual) + XCTAssertEqual(LoadPillState.of(lane(.productive)), .higher) + XCTAssertEqual(LoadPillState.of(lane(.above)), .muchHigher) + XCTAssertEqual(LoadPillState.of(lane(nil)), .noComparison) + XCTAssertEqual(LoadPillState.of(lane(nil), provisional: true), .provisional) + XCTAssertEqual(LoadPillState.of(nil), .noComparison) + } + + func testSignedPercentHasNoDirectionWhenItRoundsToZero() { + XCTAssertEqual(LoadFormat.signedPercent(18.4), "+18 %") + XCTAssertEqual(LoadFormat.signedPercent(-7.6), "−8 %") + XCTAssertEqual(LoadFormat.signedPercent(0.4), "0 %") + XCTAssertEqual(LoadFormat.signedPercent(-0.4), "0 %") + } +} diff --git a/Tools/translations/de.json b/Tools/translations/de.json index 6a4d9ec727..33dc49555b 100644 --- a/Tools/translations/de.json +++ b/Tools/translations/de.json @@ -1414,5 +1414,18 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP verwendet die folgenden Inhalte Dritter unter deren eigener Lizenz. Eine Lizenz in einem Bereich (Code, Daten, Medien) gilt nicht automatisch auch für einen anderen.", "Third-party content NOOP uses under its own licence.": "Inhalte Dritter, die NOOP unter deren eigener Lizenz verwendet.", "Workout title": "Workout-Titel", - "Workout title (optional)": "Workout-Titel (optional)" + "Workout title (optional)": "Workout-Titel (optional)", + "4W": "4W", + "12W": "12W", + "About usual": "Etwa wie üblich", + "Above usual": "Über dem Üblichen", + "No comparison yet": "Noch kein Vergleich", + "Opens the details": "Öffnet die Details", + "Partly unmeasured": "Teilweise ungemessen", + "Provisional": "Vorläufig", + "Usual high": "Üblich oben", + "Usual low": "Üblich unten", + "Your usual range appears after eight complete weeks.": "Dein üblicher Bereich erscheint nach acht vollständigen Wochen.", + "Your usual week": "Deine übliche Woche", + "vs. your usual": "ggü. deinem Üblichen" } diff --git a/Tools/translations/es.json b/Tools/translations/es.json index e94d318cda..108abc9e30 100644 --- a/Tools/translations/es.json +++ b/Tools/translations/es.json @@ -1415,5 +1415,18 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP utiliza el siguiente contenido de terceros bajo su propia licencia. Una licencia en un ámbito (código, datos, medios) no se considera una licencia en otro.", "Third-party content NOOP uses under its own licence.": "Contenido de terceros que NOOP utiliza bajo su propia licencia.", "Workout title": "Título del entrenamiento", - "Workout title (optional)": "Título del entrenamiento (opcional)" + "Workout title (optional)": "Título del entrenamiento (opcional)", + "4W": "4S", + "12W": "12S", + "About usual": "Como de costumbre", + "Above usual": "Por encima de lo habitual", + "No comparison yet": "Aún sin comparación", + "Opens the details": "Abre los detalles", + "Partly unmeasured": "Medido en parte", + "Provisional": "Provisional", + "Usual high": "Límite superior habitual", + "Usual low": "Límite inferior habitual", + "Your usual range appears after eight complete weeks.": "Tu rango habitual aparece tras ocho semanas completas.", + "Your usual week": "Tu semana habitual", + "vs. your usual": "frente a lo habitual" } diff --git a/Tools/translations/fr.json b/Tools/translations/fr.json index 58be4340b6..2a90c8f485 100644 --- a/Tools/translations/fr.json +++ b/Tools/translations/fr.json @@ -1416,5 +1416,18 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP utilise le contenu tiers suivant sous sa propre licence. Une licence dans un domaine (code, données, médias) n’est pas considérée comme une licence dans un autre.", "Third-party content NOOP uses under its own licence.": "Contenu tiers que NOOP utilise sous sa propre licence.", "Workout title": "Titre de l’entraînement", - "Workout title (optional)": "Titre de l’entraînement (facultatif)" + "Workout title (optional)": "Titre de l’entraînement (facultatif)", + "4W": "4S", + "12W": "12S", + "About usual": "Comme d’habitude", + "Above usual": "Au-dessus de l’habitude", + "No comparison yet": "Pas encore de comparaison", + "Opens the details": "Ouvre les détails", + "Partly unmeasured": "Partiellement non mesuré", + "Provisional": "Provisoire", + "Usual high": "Haut habituel", + "Usual low": "Bas habituel", + "Your usual range appears after eight complete weeks.": "Votre plage habituelle apparaît après huit semaines complètes.", + "Your usual week": "Votre semaine habituelle", + "vs. your usual": "vs votre habitude" } diff --git a/Tools/translations/it.json b/Tools/translations/it.json index f5913484ff..c9f65bdd6c 100644 --- a/Tools/translations/it.json +++ b/Tools/translations/it.json @@ -1509,5 +1509,18 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP utilizza i seguenti contenuti di terze parti secondo la loro licenza. Una licenza in un ambito (codice, dati, media) non vale automaticamente anche per un altro.", "Third-party content NOOP uses under its own licence.": "Contenuti di terze parti che NOOP utilizza secondo la loro licenza.", "Workout title": "Titolo dell’allenamento", - "Workout title (optional)": "Titolo dell’allenamento (facoltativo)" + "Workout title (optional)": "Titolo dell’allenamento (facoltativo)", + "4W": "4S", + "12W": "12S", + "About usual": "Come al solito", + "Above usual": "Sopra il solito", + "No comparison yet": "Ancora nessun confronto", + "Opens the details": "Apre i dettagli", + "Partly unmeasured": "Misurato solo in parte", + "Provisional": "Provvisorio", + "Usual high": "Limite alto abituale", + "Usual low": "Limite basso abituale", + "Your usual range appears after eight complete weeks.": "Il tuo intervallo abituale compare dopo otto settimane complete.", + "Your usual week": "La tua settimana abituale", + "vs. your usual": "rispetto al solito" } diff --git a/Tools/translations/pl.json b/Tools/translations/pl.json index ef83ca4116..c971d11c3c 100644 --- a/Tools/translations/pl.json +++ b/Tools/translations/pl.json @@ -2551,5 +2551,18 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP korzysta z poniższych treści innych firm na podstawie ich własnej licencji. Licencja w jednym obszarze (kod, dane, multimedia) nie jest traktowana jako licencja w innym.", "Third-party content NOOP uses under its own licence.": "Treści innych firm, z których NOOP korzysta na podstawie ich własnej licencji.", "Workout title": "Tytuł treningu", - "Workout title (optional)": "Tytuł treningu (opcjonalnie)" + "Workout title (optional)": "Tytuł treningu (opcjonalnie)", + "4W": "4T", + "12W": "12T", + "About usual": "Jak zwykle", + "Above usual": "Powyżej zwykłego", + "No comparison yet": "Brak porównania", + "Opens the details": "Otwiera szczegóły", + "Partly unmeasured": "Częściowo niezmierzone", + "Provisional": "Wstępnie", + "Usual high": "Zwykła górna granica", + "Usual low": "Zwykła dolna granica", + "Your usual range appears after eight complete weeks.": "Twój zwykły zakres pojawi się po ośmiu pełnych tygodniach.", + "Your usual week": "Twój zwykły tydzień", + "vs. your usual": "wobec zwykłego" } diff --git a/Tools/translations/pt-PT.json b/Tools/translations/pt-PT.json index ba3ed531e8..1f9800a763 100644 --- a/Tools/translations/pt-PT.json +++ b/Tools/translations/pt-PT.json @@ -1415,5 +1415,18 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "A NOOP utiliza o seguinte conteúdo de terceiros ao abrigo da sua própria licença. Uma licença num domínio (código, dados, multimédia) não é considerada uma licença noutro.", "Third-party content NOOP uses under its own licence.": "Conteúdo de terceiros que a NOOP utiliza ao abrigo da sua própria licença.", "Workout title": "Título do treino", - "Workout title (optional)": "Título do treino (opcional)" + "Workout title (optional)": "Título do treino (opcional)", + "4W": "4S", + "12W": "12S", + "About usual": "Como habitualmente", + "Above usual": "Acima do habitual", + "No comparison yet": "Ainda sem comparação", + "Opens the details": "Abre os detalhes", + "Partly unmeasured": "Parcialmente por medir", + "Provisional": "Provisório", + "Usual high": "Limite superior habitual", + "Usual low": "Limite inferior habitual", + "Your usual range appears after eight complete weeks.": "O teu intervalo habitual aparece após oito semanas completas.", + "Your usual week": "A tua semana habitual", + "vs. your usual": "face ao habitual" } diff --git a/Tools/translations/ru.json b/Tools/translations/ru.json index 9992fb4b02..9c6c4326e2 100644 --- a/Tools/translations/ru.json +++ b/Tools/translations/ru.json @@ -1392,5 +1392,18 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP использует следующий сторонний контент на условиях его собственной лицензии. Лицензия в одной области (код, данные, медиа) не считается лицензией в другой.", "Third-party content NOOP uses under its own licence.": "Сторонний контент, который NOOP использует на условиях его собственной лицензии.", "Workout title": "Название тренировки", - "Workout title (optional)": "Название тренировки (необязательно)" + "Workout title (optional)": "Название тренировки (необязательно)", + "4W": "4Н", + "12W": "12Н", + "About usual": "Как обычно", + "Above usual": "Выше обычного", + "No comparison yet": "Сравнения пока нет", + "Opens the details": "Открывает подробности", + "Partly unmeasured": "Частично не измерено", + "Provisional": "Предварительно", + "Usual high": "Обычный максимум", + "Usual low": "Обычный минимум", + "Your usual range appears after eight complete weeks.": "Ваш обычный диапазон появится после восьми полных недель.", + "Your usual week": "Ваша обычная неделя", + "vs. your usual": "к вашему обычному" } diff --git a/Tools/translations/zh-Hans.json b/Tools/translations/zh-Hans.json index c3195e54eb..bce1b8cbf8 100644 --- a/Tools/translations/zh-Hans.json +++ b/Tools/translations/zh-Hans.json @@ -1516,5 +1516,18 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP 在其自身许可下使用以下第三方内容。一个领域(代码、数据、媒体)的许可并不等同于另一个领域的许可。", "Third-party content NOOP uses under its own licence.": "NOOP 在其自身许可下使用的第三方内容。", "Workout title": "训练标题", - "Workout title (optional)": "训练标题(可选)" + "Workout title (optional)": "训练标题(可选)", + "4W": "4周", + "12W": "12周", + "About usual": "与平常相当", + "Above usual": "高于平常", + "No comparison yet": "暂无对比", + "Opens the details": "打开详情", + "Partly unmeasured": "部分未测量", + "Provisional": "初步", + "Usual high": "平常上限", + "Usual low": "平常下限", + "Your usual range appears after eight complete weeks.": "满八个完整周后显示你的平常范围。", + "Your usual week": "你的平常一周", + "vs. your usual": "对比平常" } diff --git a/Tools/translations/zh-Hant.json b/Tools/translations/zh-Hant.json index 13a74fb020..fe95056138 100644 --- a/Tools/translations/zh-Hant.json +++ b/Tools/translations/zh-Hant.json @@ -1570,5 +1570,18 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP 在其自身授權下使用以下第三方內容。一個領域(程式碼、資料、媒體)的授權並不代表其他領域的授權。", "Third-party content NOOP uses under its own licence.": "NOOP 在其自身授權下使用的第三方內容。", "Workout title": "訓練標題", - "Workout title (optional)": "訓練標題(選填)" + "Workout title (optional)": "訓練標題(選填)", + "4W": "4週", + "12W": "12週", + "About usual": "與平常相當", + "Above usual": "高於平常", + "No comparison yet": "暫無對比", + "Opens the details": "打開詳情", + "Partly unmeasured": "部分未測量", + "Provisional": "初步", + "Usual high": "平常上限", + "Usual low": "平常下限", + "Your usual range appears after eight complete weeks.": "滿八個完整週後顯示你的平常範圍。", + "Your usual week": "你的平常一週", + "vs. your usual": "對比平常" } From 8e0ea74b9bcf39c67728494be37df1c57925bed4 Mon Sep 17 00:00:00 2001 From: DX23876 <176692557+DX23876@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:03:39 +0200 Subject: [PATCH 5/5] feat(training): rebuild Training Load on the shared design kit The screen stacked thirteen full-width cards under a dual ring whose two arcs and centre words were hard to tell apart. It now opens on two lane cards (percentage, status, measured figure, trend, coverage, caveat and the provisional strength amount), a maturity line, the duplicate and overload notices, four tiles that open the full Adaptation, Recovery, 8-week and Session load cards, the development cards, week shape, and explainer rows for maturity, data coverage and method. The dual ring, its reading type and hero surface, the unused statement and legend cards and the dial scale helpers are removed. Analysis migration required: no --- Strand/Screens/TrainingDesignKit.swift | 20 +- Strand/Screens/TrainingLoadView.swift | 605 +++++++++------------ Strand/Screens/TrainingStatusVisuals.swift | 421 +------------- 3 files changed, 266 insertions(+), 780 deletions(-) diff --git a/Strand/Screens/TrainingDesignKit.swift b/Strand/Screens/TrainingDesignKit.swift index 6f01c95629..981702e7f1 100644 --- a/Strand/Screens/TrainingDesignKit.swift +++ b/Strand/Screens/TrainingDesignKit.swift @@ -200,7 +200,7 @@ struct LoadHeroCard: View { } } .padding(compact ? NoopMetrics.space3 : NoopMetrics.cardPadding) - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(LaneHeroSurface(lane: lane)) .accessibilityElement(children: .combine) .onChangeCompat(of: percent) { newValue in @@ -496,7 +496,7 @@ struct SummaryTile: View { let symbol: String let tint: Color let title: String - let headline: String + var headline: String? = nil var detail: String? = nil let action: () -> Void @ViewBuilder var mini: () -> Mini @@ -510,21 +510,25 @@ struct SummaryTile: View { .font(StrandFont.caption.weight(.semibold)) .foregroundStyle(StrandPalette.textSecondary) .lineLimit(1) + .minimumScaleFactor(0.7) Spacer(minLength: 0) Image(systemName: "chevron.right") .font(StrandFont.caption.weight(.semibold)) .foregroundStyle(StrandPalette.textTertiary) } - Text(headline) - .font(StrandFont.headline) - .foregroundStyle(StrandPalette.textPrimary) - .lineLimit(2) - .minimumScaleFactor(0.8) + if let headline { + Text(headline) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(2) + .minimumScaleFactor(0.8) + } if let detail { Text(detail) .font(StrandFont.caption) .foregroundStyle(StrandPalette.textSecondary) - .lineLimit(2) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 0) mini() diff --git a/Strand/Screens/TrainingLoadView.swift b/Strand/Screens/TrainingLoadView.swift index ecdce785ad..114ee50a77 100644 --- a/Strand/Screens/TrainingLoadView.swift +++ b/Strand/Screens/TrainingLoadView.swift @@ -430,9 +430,14 @@ struct TrainingLoadView: View { @EnvironmentObject private var repo: Repository @StateObject private var model = TrainingLoadModel() @State private var shownVO2: Double = 0 - @State private var adviceBounce = 0 + @State private var openSummary: SummaryDetail? @Environment(\.accessibilityReduceMotion) private var reduceMotion + private enum SummaryDetail: String, Identifiable { + case adaptation, recovery, history, session + var id: String { rawValue } + } + var body: some View { ScreenScaffold(title: "Training Load", subtitle: "Three views of training, each in the unit that fits it.", @@ -440,23 +445,19 @@ struct TrainingLoadView: View { if !model.loaded { ProgressView().frame(maxWidth: .infinity) } else { - hero - precisionCard.trainingCardEntrance() + // Named sections for `--demo-scroll-to` screenshot QA (DEBUG only; ids are inert otherwise). + hero.id("hero") + maturityLine duplicateReviewCard sustainedCard - adaptationCard.trainingCardEntrance().id("statement") - recoveryCard.trainingCardEntrance() - // Named sections for `--demo-scroll-to` screenshot QA (DEBUG only; ids are inert otherwise). - historyCard.trainingCardEntrance().id("history") - strengthSummaryCard.trainingCardEntrance().id("lifts") - vo2maxCard.trainingCardEntrance().id("cardio") - sessionCard.trainingCardEntrance() + summaryGrid.trainingCardEntrance().id("statement") + developmentCards.trainingCardEntrance().id("lifts") shapeCard.trainingCardEntrance().id("shape") - basisCard.trainingCardEntrance() - methodCard.trainingCardEntrance() + explainers.trainingCardEntrance().id("method") } } .task(id: repo.refreshSeq) { await model.load(repo: repo) } + .sheet(item: $openSummary) { detail in summarySheet(detail) } } @ViewBuilder private var duplicateReviewCard: some View { @@ -509,89 +510,226 @@ struct TrainingLoadView: View { } } - // MARK: - The instrument + // MARK: - The two lanes - /// One instrument for both lanes: strength on the outer arc, cardio on the inner one, each with its - /// own knob and its own verdict underneath. - /// - /// Deliberately NOT one combined ring with one word in the middle. The two lanes are measured in - /// different units and the fork's decision log is explicit that they are never blended into a single - /// score — so the ring shares a scale, and everything that could be mistaken for a joint verdict - /// stays split in two. + /// Strength and cardio side by side, each in its own unit and colour. Never one combined figure: the + /// two lanes are measured differently, and a blended score would need an invented exchange rate. private var hero: some View { - VStack(spacing: NoopMetrics.space3) { - LoadDualRing(strength: strengthRingReading, cardio: cardioRingReading) - laneSummary(symbol: "figure.strengthtraining.traditional", title: "Strength", - lane: model.strength?.status, figure: strengthFigure, evidence: strengthEvidence, - caveat: strengthCaveat) - Divider().overlay(StrandPalette.hairline) - laneSummary(symbol: "heart.fill", title: "Cardiovascular", - lane: model.cardio?.status, figure: cardioFigure, evidence: cardioEvidence, - caveat: cardioCaveat) + HStack(alignment: .top, spacing: NoopMetrics.gap) { + laneLink(to: .strength) { + LoadHeroCard(lane: .strength, percent: model.strength?.trend?.percentChange, + state: LoadPillState.of(model.strength, + provisional: model.provisionalStrengthRing != nil), + figure: strengthFigure, trend: model.ratios.compactMap(\.strength), + coverage: strengthEvidence, caveat: strengthCaveat, note: provisionalNote, + compact: true) + } + laneLink(to: .cardio) { + LoadHeroCard(lane: .cardio, percent: model.cardio?.trend?.percentChange, + state: LoadPillState.of(model.cardio), figure: cardioFigure, + trend: model.ratios.compactMap(\.cardio), coverage: cardioEvidence, + caveat: cardioCaveat, compact: true) + } } - .padding(NoopMetrics.cardPadding) - .frame(maxWidth: .infinity) - .background(TrainingHeroSurface(leading: model.strength?.status?.status.color ?? StrandPalette.textTertiary, - trailing: model.cardio?.status?.status.color ?? StrandPalette.textTertiary)) + .fixedSize(horizontal: false, vertical: true) } - private var strengthRingReading: LoadRingReading? { - if let status = model.strength?.status { return LoadRingReading(status) } - return model.provisionalStrengthRing.map(LoadRingReading.init) + /// On iOS a lane opens its own screen. The macOS detail column has no navigation stack to push onto, + /// and both screens sit in its sidebar anyway. + @ViewBuilder private func laneLink(to lane: TrainingLane, + @ViewBuilder label: () -> Label) -> some View { + #if os(iOS) + NavigationLink { + if lane == .strength { StrengthView() } else { CardioView() } + } label: { + label() + } + .buttonStyle(.plain) + .strandPressable() + #else + label() + #endif } - private var cardioRingReading: LoadRingReading? { - model.cardio?.status.map(LoadRingReading.init) + /// Before a personal comparison exists the strength lane can still say roughly how much the week was. + private var provisionalNote: String? { + guard model.strength?.status == nil, let reading = model.provisionalStrengthRing else { return nil } + let amount: String + switch reading.band { + case .low: amount = String(localized: "Low") + case .moderate: amount = String(localized: "Moderate") + case .high: amount = String(localized: "High") + case .veryHigh: amount = String(localized: "Very high") + } + return "\(amount) · \(String(localized: "provisional seven-day amount"))" } - /// One lane under the ring: the same symbol its knob carries, its verdict, its figure and what the - /// verdict rests on. The symbol is what maps a row to an arc, so it is never dropped. - private func laneSummary(symbol: String, title: LocalizedStringKey, lane: LaneStatus?, - figure: String?, evidence: String?, caveat: String? = nil) -> some View { - HStack(alignment: .top, spacing: NoopMetrics.space3) { - StatusBadge(symbol: symbol, color: lane?.status.color ?? StrandPalette.textTertiary, size: 30) - // The ratio rides on the TITLE line, not in a column of its own: as a third column it - // squeezed the figure so hard that "43,6 gewichtete Sätze · −21 %" broke after the minus. - VStack(alignment: .leading, spacing: 2) { - HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(title) - .font(StrandFont.subhead.weight(.semibold)) - .foregroundStyle(StrandPalette.textPrimary) - if let lane { - Text(lane.status.label) - .font(StrandFont.caption.weight(.semibold)) - .foregroundStyle(lane.status.color) - .lineLimit(1) - .minimumScaleFactor(0.7) - } - Spacer(minLength: 6) - Text(lane.map { LoadScale.ratioText($0.ratio, band: $0.band) } ?? "—") - .font(StrandFont.number(15, weight: .semibold)) - .foregroundStyle(lane?.status.color ?? StrandPalette.textTertiary) - .lineLimit(1) - .minimumScaleFactor(0.65) - } - if let figure { - Text(figure) - .font(StrandFont.captionNumber) + @ViewBuilder private var maturityLine: some View { + if currentMaturity != .personalBaseline { + HStack(alignment: .top, spacing: NoopMetrics.space2) { + Image(systemName: maturitySymbol) + .font(StrandFont.caption.weight(.semibold)) + .foregroundStyle(StrandPalette.metricCyan) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 1) { + Text(maturityTitle) + .font(StrandFont.caption.weight(.semibold)) .foregroundStyle(StrandPalette.textPrimary) - .fixedSize(horizontal: false, vertical: true) - } - if let evidence { - Text(evidence) + Text(maturityDetail) .font(StrandFont.caption) .foregroundStyle(StrandPalette.textSecondary) .fixedSize(horizontal: false, vertical: true) } - if let caveat { - Label(caveat, systemImage: "exclamationmark.triangle.fill") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.statusWarning) - .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, NoopMetrics.space1) + .accessibilityElement(children: .combine) + } + } + + // MARK: - At a glance + + private var summaryGrid: some View { + LazyVGrid(columns: [GridItem(.flexible(), spacing: NoopMetrics.gap), + GridItem(.flexible(), spacing: NoopMetrics.gap)], + spacing: NoopMetrics.gap) { + SummaryTile(symbol: "chart.line.uptrend.xyaxis", tint: StrandPalette.statusPositive, + title: String(localized: "Adaptation"), headline: nil, + action: { openSummary = .adaptation }) { + VStack(alignment: .leading, spacing: 6) { + adaptationMini(.strength, model.strengthAdaptation) + adaptationMini(.cardio, model.cardiovascularAdaptation) } } + SummaryTile(symbol: "moon.zzz.fill", tint: recoveryTint, title: String(localized: "Recovery"), + headline: nil, detail: recoverySummary, action: { openSummary = .recovery }) { + recoveryDots + } + SummaryTile(symbol: "calendar", tint: StrandPalette.metricCyan, + title: String(localized: "Last 8 weeks"), headline: nil, + action: { openSummary = .history }) { + VStack(alignment: .leading, spacing: 6) { + historyStrip(.strength) + historyStrip(.cardio) + } + } + SummaryTile(symbol: "person.fill.checkmark", tint: StrandPalette.metricCyan, + title: String(localized: "Session load"), headline: sessionText(model.session), + detail: model.session?.trend.map { + "\(signedPercent($0.percentChange)) · \(comparisonText($0.percentChange))" + } ?? String(localized: "Needs two weeks of measured history"), + action: { openSummary = .session }) { + EmptyView() + } } - .accessibilityElement(children: .combine) + } + + private func adaptationMini(_ lane: TrainingLane, _ reading: TrainingAdaptationReading?) -> some View { + let presentation = adaptationPresentation(reading) + return HStack(spacing: 6) { + Image(systemName: lane.symbol) + .font(StrandFont.caption) + .foregroundStyle(lane.color) + .frame(width: 14) + Label(presentation.1, systemImage: presentation.0) + .font(StrandFont.caption) + .foregroundStyle(presentation.2) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var recoveryTint: Color { + switch model.recovery?.state { + case .strained: return StrandPalette.statusWarning + case .holding: return StrandPalette.statusPositive + default: return StrandPalette.textTertiary + } + } + + private var recoveryDots: some View { + HStack(spacing: NoopMetrics.space2) { + recoveryDot("HRV", key: "hrv") + recoveryDot("Resting HR", key: "rhr") + recoveryDot("Breathing", key: "respRate") + } + } + + private func recoveryDot(_ title: LocalizedStringKey, key: String) -> some View { + let read = model.recovery?.readOnLatestNight.contains(key) ?? false + let flagging = model.recovery?.flaggingOnLatestNight.contains(key) ?? false + let color = !read ? StrandPalette.textTertiary + : (flagging ? StrandPalette.statusWarning : StrandPalette.statusPositive) + return HStack(spacing: 4) { + Circle().fill(color).frame(width: 7, height: 7) + Text(title).font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary).lineLimit(1) + } + .minimumScaleFactor(0.7) + } + + /// Eight weeks for one lane, oldest first: stronger colour for a week further above that lane's usual. + private func historyStrip(_ lane: TrainingLane) -> some View { + let weeks = Array(model.history.suffix(8)) + return HStack(spacing: 3) { + Image(systemName: lane.symbol) + .font(StrandFont.caption) + .foregroundStyle(lane.color) + .frame(width: 14) + ForEach(weeks.indices, id: \.self) { index in + let status = lane == .strength ? weeks[index].strength : weeks[index].cardio + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(status.map { lane.color.opacity(historyOpacity($0)) } ?? StrandPalette.surfaceInset) + .frame(height: 14) + } + } + .accessibilityHidden(true) + } + + private func historyOpacity(_ status: TrainingStatus) -> Double { + switch status { + case .detraining: return 0.3 + case .recovering, .maintaining: return 0.55 + case .productive, .unproductive: return 0.8 + case .overreaching: return 1 + } + } + + private func summarySheet(_ detail: SummaryDetail) -> some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + switch detail { + case .adaptation: adaptationCard + case .recovery: recoveryCard + case .history: historyCard + case .session: sessionCard + } + } + .padding(NoopMetrics.screenPadding) + } + .background(StrandPalette.surfaceBase.ignoresSafeArea()) + .toolbar { + ToolbarItem(placement: .confirmationAction) { Button("Done") { openSummary = nil } } + } + } + } + + private var developmentCards: some View { + AdaptiveTwoColumn { + strengthSummaryCard + } trailing: { + vo2maxCard + } + } + + private var explainers: some View { + ExplainerRows(items: [ + ExplainerItem(id: "maturity", symbol: maturitySymbol, title: maturityTitle, + subtitle: maturityDetail, text: maturityDetail), + ExplainerItem(id: "basis", symbol: "checkmark.seal", title: String(localized: "What it rests on"), + subtitle: String(localized: "Data coverage")) { basisRows }, + ExplainerItem(id: "method", symbol: "function", title: String(localized: "What each number means"), + subtitle: String(localized: "Transparent by design")) { methodRows }, + ]) } /// How thin the measurement under a verdict is — carried WITH the verdict rather than in a card @@ -619,15 +757,12 @@ struct TrainingLoadView: View { guard let lane = model.strength else { return nil } let raw = String(localized: "\(lane.sevenDayWorkingSets) working sets") let estimated = weightedSetText(lane) - guard let trend = lane.trend else { return "\(raw) · \(estimated)" } - return "\(raw) · \(estimated) · \(signedPercent(trend.percentChange))" + return "\(raw) · \(estimated)" } private var cardioFigure: String? { guard let lane = model.cardio else { return nil } - let total = lane.isLowerBound ? String(localized: "at least \(effortText(lane))") : effortText(lane) - guard let trend = lane.trend else { return total } - return "\(total) · \(signedPercent(trend.percentChange))" + return lane.isLowerBound ? String(localized: "at least \(effortText(lane))") : effortText(lane) } /// What the strength verdict rests on — a below-usual run, the lifts, or an honest "load only". @@ -649,21 +784,6 @@ struct TrainingLoadView: View { return String(localized: "\(lane.measuredCount) of \(lane.possibleCount) sessions complete · compared only with your cardiovascular history") } - private var precisionCard: some View { - NoopCard(tint: StrandPalette.metricCyan) { - HStack(alignment: .top, spacing: NoopMetrics.space3) { - StatusBadge(symbol: maturitySymbol, color: StrandPalette.metricCyan, size: 36) - VStack(alignment: .leading, spacing: 3) { - Text(maturityTitle) - .font(StrandFont.headline).foregroundStyle(StrandPalette.textPrimary) - Text(maturityDetail) - .font(StrandFont.subhead).foregroundStyle(StrandPalette.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - } - private var currentMaturity: TrainingLoadMaturity { let values = [model.strength?.relative.maturity, model.cardio?.relative.maturity].compactMap { $0 } if values.contains(.immediate) { return .immediate } @@ -721,22 +841,25 @@ struct TrainingLoadView: View { } } - private func adaptationRow(symbol: String, title: LocalizedStringKey, - reading: TrainingAdaptationReading?) -> some View { - let state = reading?.state ?? .notEnoughData - let presentation: (String, String, Color) - switch state { + /// Symbol, words and colour for one lane's adaptation reading. + private func adaptationPresentation(_ reading: TrainingAdaptationReading?) -> (String, String, Color) { + switch reading?.state ?? .notEnoughData { case .improving: - presentation = ("arrow.up.right", String(localized: "Productive development"), StrandPalette.statusPositive) + return ("arrow.up.right", String(localized: "Productive development"), StrandPalette.statusPositive) case .declining: - presentation = ("arrow.down.right", String(localized: "Performance trending down"), StrandPalette.statusWarning) + return ("arrow.down.right", String(localized: "Performance trending down"), StrandPalette.statusWarning) case .stable: - presentation = ("equal", String(localized: "Performance stable"), StrandPalette.metricCyan) + return ("equal", String(localized: "Performance stable"), StrandPalette.metricCyan) case .unclear: - presentation = ("minus", String(localized: "No clear direction"), StrandPalette.textSecondary) + return ("minus", String(localized: "No clear direction"), StrandPalette.textSecondary) case .notEnoughData: - presentation = ("hourglass", String(localized: "Adaptation not assessable yet"), StrandPalette.textTertiary) + return ("hourglass", String(localized: "Adaptation not assessable yet"), StrandPalette.textTertiary) } + } + + private func adaptationRow(symbol: String, title: LocalizedStringKey, + reading: TrainingAdaptationReading?) -> some View { + let presentation = adaptationPresentation(reading) return HStack(spacing: NoopMetrics.space3) { StatusBadge(symbol: symbol, color: presentation.2, size: 30) VStack(alignment: .leading, spacing: 2) { @@ -755,193 +878,6 @@ struct TrainingLoadView: View { return String(localized: "Below your usual since \(StatusHistoryStrip.shortDate(since))") } - // MARK: - What to do - - private struct Advice { - let symbol: String - let color: Color - let text: String - /// A second colour for a statement that speaks about BOTH lanes at once — the card then runs - /// from the lane that is falling behind to the one that is ahead. - var secondary: Color? = nil - /// Whether the card may be painted in the colour itself rather than washed with it. Yellow is - /// the exception: white text on it fails to read, and darkening the fill would turn a warning - /// into a different colour, so that state keeps the lighter treatment. - var filled = true - } - - /// The page's one statement, dressed. The DECISION is pure and lives in - /// `TrainingStatusModel.statement(strength:cardio:recovery:)`, where every pair of verdicts is - /// resolved and covered by tests; this only chooses the words, the glyph and the colours. - /// - /// It replaced a read-time ladder that stopped at its first hit and therefore named one lane: a - /// wearer whose lifting was falling away while their cardio ran well above usual was told only - /// about the cardio. - private var advice: Advice { - switch TrainingStatusModel.statement(strength: model.strength?.status?.status, - cardio: model.cardio?.status?.status, - recovery: model.recovery?.state ?? .unknown) { - case .noHistory: - return Advice(symbol: "hourglass", color: StrandPalette.textTertiary, - text: String(localized: "After two weeks of training this shows whether it is building, holding or too much."), - filled: false) - - case let .laneOnly(lane, status): - return Advice(symbol: status.symbol, color: status.color, - text: sentence(for: status, lane: lane) + " " - + String(localized: "The other lane needs two more weeks of measured history."), - filled: status != .unproductive) - - case let .aligned(status): - return Advice(symbol: status.symbol, color: status.color, - text: sentence(for: status, lane: nil), - filled: status != .unproductive) - - case let .oneBehind(lane): - let color = laneColor(lane) - return Advice(symbol: TrainingStatus.detraining.symbol, color: color, - text: lane == .strength - ? String(localized: "Your strength work is below your usual while your cardio holds steady.") - : String(localized: "Your cardio is below your usual while your strength work holds steady.")) - - case let .split(low, high, severity): - let text: String - switch (low, severity) { - case (.strength, .mild): - text = String(localized: "Plenty of cardio, little strength: your endurance is carrying this block while your lifting loses ground.") - case (.strength, .sharp): - text = String(localized: "Much more cardio than usual while your strength work has fallen away. Bring the lifting back before the cardio goes higher.") - case (.cardio, .mild): - text = String(localized: "Plenty of strength work, little cardio: your lifting is carrying this block while your endurance loses ground.") - case (.cardio, .sharp): - text = String(localized: "Much more strength work than usual while your cardio has fallen away. Bring the cardio back before the lifting goes higher.") - } - // The surface itself splits: it runs from the lane that is behind to the one that is ahead. - return Advice(symbol: "arrow.left.arrow.right", color: laneColor(high), text: text, - secondary: laneColor(low)) - - case let .excessive(lane, strained): - let text: String - if lane == .strength { - text = strained - ? String(localized: "Much more strength work than usual, and your recovery is dropping. Take a few easier days before adding more.") - : String(localized: "Much more strength work than usual. Hold here until your usual level catches up.") - } else { - text = String(localized: "Much more cardio than usual. Hold here until your usual level catches up.") - } - return Advice(symbol: TrainingStatus.overreaching.symbol, - color: TrainingStatus.overreaching.color, text: text) - - case .bothExcessive: - return Advice(symbol: TrainingStatus.overreaching.symbol, - color: TrainingStatus.overreaching.color, - text: String(localized: "Both lanes are well above your usual. Fine for a short block, but not both at once for long.")) - - case let .spinning(cardioAlsoHigh): - // Both facts, side by side. That the cardio block is what costs the lifts their progress is - // plausible and unmeasured, so the card does not say it. - return Advice(symbol: TrainingStatus.unproductive.symbol, - color: TrainingStatus.unproductive.color, - text: cardioAlsoHigh - ? String(localized: "Plenty of strength work without the lifts improving, and your cardio is well above your usual too. Decide which of the two to ease first.") - : String(localized: "Plenty of strength work, but your lifts are not improving. More volume will not fix that: look at sleep, recovery or the programme."), - filled: false) - - case .strainedRecovery: - return Advice(symbol: "moon.zzz.fill", color: TrainingStatus.unproductive.color, - text: String(localized: "Your recovery signals flagged on several recent nights. Hold your load rather than raising it."), - filled: false) - } - } - - /// The lane's own colour — the same one its arc and its row carry. - private func laneColor(_ lane: TrainingStatusModel.TrainingStatementLane) -> Color { - let status = lane == .strength ? model.strength?.status?.status : model.cardio?.status?.status - return status?.color ?? StrandPalette.textTertiary - } - - /// One verdict in a sentence, for the cases that speak about a single state. - private func sentence(for status: TrainingStatus, - lane: TrainingStatusModel.TrainingStatementLane?) -> String { - switch status { - case .detraining: - return String(localized: "You have been training well below your usual level. If this is not a planned break, restart with a few easy sessions.") - case .recovering: - return String(localized: "A lighter stretch after a hard phase. Good timing to let strength and fitness settle.") - case .maintaining: - return String(localized: "You are holding your level. To build, raise the load in small steps.") - case .productive: - return model.recovery?.state == .holding - ? String(localized: "Your build is working: load at or above your usual, and your recovery is keeping up.") - : String(localized: "Your build is working: load at or above your usual, and it is paying off.") - case .unproductive: - return String(localized: "Plenty of strength work, but your lifts are not improving. More volume will not fix that: look at sleep, recovery or the programme.") - case .overreaching: - return lane == .cardio - ? String(localized: "Much more cardio than usual. Hold here until your usual level catches up.") - : String(localized: "Much more strength work than usual. Hold here until your usual level catches up.") - } - } - - private var adviceCard: some View { - let advice = advice - let ink = advice.filled ? StrandPalette.onDarkPrimary : StrandPalette.textPrimary - return TrainingWashCard(color: advice.color, watermark: advice.symbol, filled: advice.filled, - secondary: advice.secondary) { - VStack(alignment: .leading, spacing: NoopMetrics.space3) { - HStack(alignment: .top, spacing: NoopMetrics.space3) { - // On a filled card the glyph stands on its own: a coloured badge on the same colour - // would disappear into it. - if advice.filled { - Image(systemName: advice.symbol) - .font(StrandFont.rounded(30, weight: .bold)) - .foregroundStyle(ink) - .trainingSymbolBounce(trigger: adviceBounce) - .accessibilityHidden(true) - } else { - StatusBadge(symbol: advice.symbol, color: advice.color, size: 44, - bounceTrigger: adviceBounce) - } - Text(advice.text) - .font(StrandFont.headline) - .foregroundStyle(ink) - .fixedSize(horizontal: false, vertical: true) - Spacer(minLength: 0) - } - HStack(spacing: NoopMetrics.space2) { - stageTile("Strength", lane: model.strength?.status, filled: advice.filled) - stageTile("Cardio", lane: model.cardio?.status, filled: advice.filled) - } - } - } - .task(id: advice.text) { - guard !reduceMotion else { return } - try? await Task.sleep(nanoseconds: 900_000_000) - adviceBounce += 1 - } - } - - /// One lane's ratio inside the statement card, so the sentence above it is answerable without - /// scrolling back to the ring. - private func stageTile(_ title: LocalizedStringKey, lane: LaneStatus?, filled: Bool) -> some View { - VStack(alignment: .leading, spacing: 1) { - Text(title) - .font(StrandFont.caption) - .foregroundStyle(filled ? StrandPalette.onDarkSecondary : StrandPalette.textSecondary) - Text(lane.map { LoadScale.ratioText($0.ratio, band: $0.band) } ?? "—") - .font(StrandFont.number(17, weight: .semibold)) - .foregroundStyle(filled ? StrandPalette.onDarkPrimary : StrandPalette.textPrimary) - .lineLimit(1) - .minimumScaleFactor(0.6) - } - .padding(.horizontal, 12) - .padding(.vertical, 9) - .frame(maxWidth: .infinity, alignment: .leading) - .background(RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(filled ? StrandPalette.onDarkPrimary.opacity(0.18) : StrandPalette.surfaceInset)) - .accessibilityElement(children: .combine) - } - // MARK: - Lasting overreaching /// Shown only when overreaching has lasted three week-ends with that lane's performance falling and @@ -1314,18 +1250,13 @@ struct TrainingLoadView: View { : parts.joined(separator: " · ") } - private var basisCard: some View { - VStack(alignment: .leading, spacing: NoopMetrics.gap) { - SectionHeader("What it rests on", overline: "Data coverage") - NoopCard { - VStack(alignment: .leading, spacing: NoopMetrics.space3) { - basisRow(symbol: "figure.strengthtraining.traditional", share: coverageShare(model.strength), - text: strengthCoverage(model.strength)) - Divider().overlay(StrandPalette.hairline) - basisRow(symbol: "heart.fill", share: coverageShare(model.cardio), - text: cardioCoverage(model.cardio)) - } - } + private var basisRows: some View { + VStack(alignment: .leading, spacing: NoopMetrics.space3) { + basisRow(symbol: "figure.strengthtraining.traditional", share: coverageShare(model.strength), + text: strengthCoverage(model.strength)) + Divider().overlay(StrandPalette.hairline) + basisRow(symbol: "heart.fill", share: coverageShare(model.cardio), + text: cardioCoverage(model.cardio)) } } @@ -1383,51 +1314,21 @@ struct TrainingLoadView: View { // MARK: - Legend and method - private var legendCard: some View { - VStack(alignment: .leading, spacing: NoopMetrics.gap) { - SectionHeader("The six states", overline: "Legend") - NoopCard { - VStack(alignment: .leading, spacing: NoopMetrics.space3) { - ForEach(TrainingStatus.allCases, id: \.self) { status in - HStack(alignment: .top, spacing: NoopMetrics.space3) { - StatusBadge(symbol: status.symbol, color: status.color, size: 34, cornerRadius: 10) - VStack(alignment: .leading, spacing: 2) { - Text(status.label) - .font(StrandFont.subhead.weight(.semibold)) - .foregroundStyle(StrandPalette.textPrimary) - Text(status.meaning) - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - } - .accessibilityElement(children: .combine) - } - } - } - } - } - - private var methodCard: some View { - VStack(alignment: .leading, spacing: NoopMetrics.gap) { - SectionHeader("What each number means", overline: "Transparent by design") - NoopCard { - VStack(alignment: .leading, spacing: NoopMetrics.space3) { - // The lane titles, not bare "Strength/Cardio/Session": the method rows explain the three - // cards above and have to name them the same way in every language. - methodRow("Strength load", "Working sets weighted by proximity to failure. Tonnage remains a training statistic, not the load.") - Divider().overlay(StrandPalette.hairline) - methodRow("Cardiovascular load", "Classic Edwards TRIMP from time in percentages of your maximum heart rate. NOOP band data wins; workout-associated Health data fills only when the band trace is incomplete.") - Divider().overlay(StrandPalette.hairline) - methodRow("Session load", "Your whole-session RPE × duration. Add it from any workout detail; missing ratings are never guessed.") - Divider().overlay(StrandPalette.hairline) - methodRow("How comparison works", "NOOP compares the last 7 days with an earlier, non-overlapping baseline. A partial training day stays visible as a lower bound but leaves the comparison. After eight complete weeks, robust personal variation replaces fixed population-style bands.") - Divider().overlay(StrandPalette.hairline) - methodRow("Load and adaptation", "Relative load is descriptive. Productive development requires a clear performance trend: estimated one-rep max for strength or VO₂max within one consistent measurement method for cardiovascular training.") - Divider().overlay(StrandPalette.hairline) - methodRow("Sources", "Edwards 1993 · Banister 1991 · Foster 2001 · Bosquet et al. 2013 · Meeusen et al. 2013 · Pelland et al. 2024 · Robinson et al. 2024") - } - } + private var methodRows: some View { + VStack(alignment: .leading, spacing: NoopMetrics.space3) { + // The lane titles, not bare "Strength/Cardio/Session": the method rows explain the three + // cards above and have to name them the same way in every language. + methodRow("Strength load", "Working sets weighted by proximity to failure. Tonnage remains a training statistic, not the load.") + Divider().overlay(StrandPalette.hairline) + methodRow("Cardiovascular load", "Classic Edwards TRIMP from time in percentages of your maximum heart rate. NOOP band data wins; workout-associated Health data fills only when the band trace is incomplete.") + Divider().overlay(StrandPalette.hairline) + methodRow("Session load", "Your whole-session RPE × duration. Add it from any workout detail; missing ratings are never guessed.") + Divider().overlay(StrandPalette.hairline) + methodRow("How comparison works", "NOOP compares the last 7 days with an earlier, non-overlapping baseline. A partial training day stays visible as a lower bound but leaves the comparison. After eight complete weeks, robust personal variation replaces fixed population-style bands.") + Divider().overlay(StrandPalette.hairline) + methodRow("Load and adaptation", "Relative load is descriptive. Productive development requires a clear performance trend: estimated one-rep max for strength or VO₂max within one consistent measurement method for cardiovascular training.") + Divider().overlay(StrandPalette.hairline) + methodRow("Sources", "Edwards 1993 · Banister 1991 · Foster 2001 · Bosquet et al. 2013 · Meeusen et al. 2013 · Pelland et al. 2024 · Robinson et al. 2024") } } diff --git a/Strand/Screens/TrainingStatusVisuals.swift b/Strand/Screens/TrainingStatusVisuals.swift index 3ec7349f48..8553a4f030 100644 --- a/Strand/Screens/TrainingStatusVisuals.swift +++ b/Strand/Screens/TrainingStatusVisuals.swift @@ -86,316 +86,11 @@ extension TrainingLoadBand { // MARK: - The scale -/// The dial's 0.5–1.6 scale and the one colour ramp drawn along it. +/// How a load ratio is written beside the chart. enum LoadScale { - static let low = 0.5 - static let high = 1.6 - - static func fraction(for ratio: Double) -> Double { - min(max((ratio - low) / (high - low), 0), 1) - } - static func ratioText(_ ratio: Double, band: TrainingLoadBand) -> String { String(localized: "\(ratio.formatted(.number.precision(.fractionLength(2)))) × usual") } - - /// The ratio rounded TOWARD its own zone, so the number never contradicts the word beside it. Plain - /// rounding showed 0.7996 as "0.80" under "Recovering", although 0.80 is where maintaining begins; - /// here it reads 0.79. Above 1.3 rounds up (1.3004 → 1.31), and inside a zone the value is kept - /// within that zone's bounds (0.996 → 0.99, not 1.00). - static func displayRatio(_ ratio: Double, band: TrainingLoadBand) -> Double { - let hundredths = ratio * 100 - switch band { - case .below: return floor(hundredths) / 100 - case .above: return ceil(hundredths) / 100 - case .maintaining: return min(max(hundredths.rounded() / 100, 0.8), 0.99) - case .productive: return min(max(hundredths.rounded() / 100, 1.0), 1.3) - } - } - - /// The four zone colours as one ramp, with a short blend at each threshold: the ring reads as one - /// instrument rather than four pieces stuck together, and every zone still keeps its own colour. - /// An orange lift just past 1.3 is the ramp heating up into the red. - static var rampStops: [Gradient.Stop] { - let blend = 0.022 - let toMaintaining = fraction(for: TrainingStatusModel.detrainingBelow) - let toProductive = fraction(for: TrainingStatusModel.productiveFrom) - let toOverreaching = fraction(for: TrainingStatusModel.overreachingAbove) - return [ - .init(color: TrainingLoadBand.below.color, location: 0), - .init(color: TrainingLoadBand.below.color, location: toMaintaining - blend), - .init(color: TrainingLoadBand.maintaining.color, location: toMaintaining + blend), - .init(color: TrainingLoadBand.maintaining.color, location: toProductive - blend), - .init(color: TrainingLoadBand.productive.color, location: toProductive + blend), - .init(color: TrainingLoadBand.productive.color, location: toOverreaching - blend), - .init(color: StrandPalette.metricAmber, location: toOverreaching + 0.012), - .init(color: TrainingLoadBand.above.color, location: toOverreaching + 0.07), - .init(color: TrainingLoadBand.above.color, location: 1), - ] - } -} - -// MARK: - The instrument - -/// Rendering-only reading for one arc. A provisional amount can occupy the same instrument without -/// being promoted to `LaneStatus`, which is reserved for personal comparison and adaptation logic. -struct LoadRingReading: Equatable { - let fraction: Double - let color: Color - let label: String - let source: LoadRingSource - let ratio: Double? - let ratioBand: TrainingLoadBand? - let isProvisional: Bool - - init(_ status: LaneStatus) { - fraction = LoadScale.fraction(for: status.ratio) - color = status.status.color - label = status.status.label - source = .personalRelativeLoad - ratio = status.ratio - ratioBand = status.band - isProvisional = false - } - - init(_ reading: ProvisionalStrengthRingReading) { - fraction = reading.fraction - source = reading.source - ratio = nil - ratioBand = nil - isProvisional = true - switch reading.band { - case .low: - label = String(localized: "Low") - color = StrandPalette.restColor - case .moderate: - label = String(localized: "Moderate") - color = StrandPalette.statusPositive - case .high: - label = String(localized: "High") - color = StrandPalette.metricCyan - case .veryHigh: - label = String(localized: "Very high") - color = StrandPalette.metricAmber - } - } -} - -/// Both lanes on one 240° scale: strength on the outer arc, cardio on the inner one. -/// -/// Sharing the scale is the point — "further round" means the same thing on both arcs, so the two lanes -/// can be compared at a glance instead of by reading two separate dials. What the ring deliberately does -/// NOT do is merge them: each arc keeps its own knob, its own lit length and its own word in the middle, -/// because a lifting week and a running week are measured in different units and this fork's decision log -/// rules out a single blended score. -/// -/// Each knob carries its lane's own symbol, and the rows beneath the ring repeat that symbol — that is -/// what maps an arc to a lane without a legend. Arc length shows the uncoupled 7-day comparison; colour -/// comes from that lane's current personal classification rather than fixed population thresholds. -struct LoadDualRing: View { - let strength: LoadRingReading? - let cardio: LoadRingReading? - var diameter: CGFloat = 252 - - @State private var shownStrength: Double = 0 - @State private var shownCardio: Double = 0 - @State private var bounce = 0 - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - private let outerWidth: CGFloat = 15 - private let innerWidth: CGFloat = 12 - /// Wide enough that the two knobs never fuse into one blob when both lanes sit at the same ratio. - private let ringGap: CGFloat = 10 - private let labelInset: CGFloat = 8 - private let startDegrees = 150.0 - private let spanDegrees = 240.0 - - private var outerRadius: CGFloat { (diameter - 2 * labelInset - outerWidth) / 2 } - private var innerRadius: CGFloat { outerRadius - outerWidth / 2 - ringGap - innerWidth / 2 } - /// The padding that puts `RecoveryArc` on the inner radius, given it insets by half its own width. - private var innerPadding: CGFloat { (diameter - innerWidth) / 2 - innerRadius } - - var body: some View { - ZStack { - glow - rings - knobs - centre - } - .frame(width: diameter, height: diameter) - // The 240° arc leaves the bottom sixth of its square empty; pull what follows up into it rather - // than leaving a gap under the instrument. - .padding(.bottom, -diameter * 0.13) - .accessibilityElement(children: .ignore) - .accessibilityLabel(Text("Training Load")) - .accessibilityValue(Text(accessibilityValue)) - .task(id: "\(strength?.fraction ?? -1)|\(cardio?.fraction ?? -1)") { - let outer = strength?.fraction ?? 0 - let inner = cardio?.fraction ?? 0 - if reduceMotion { - shownStrength = outer - shownCardio = inner - } else { - withAnimation(StrandMotion.drawIn) { - shownStrength = outer - shownCardio = inner - } - // The glyphs land once both arcs have filled. - try? await Task.sleep(nanoseconds: 650_000_000) - bounce += 1 - } - } - } - - private var accessibilityValue: String { - var parts: [String] = [] - if let strength { - parts.append(accessibilityPart(title: String(localized: "Strength"), lane: strength)) - } - if let cardio { - parts.append(accessibilityPart(title: String(localized: "Cardio"), lane: cardio)) - } - return parts.isEmpty ? String(localized: "Needs two weeks of measured history") - : parts.joined(separator: ", ") - } - - private func accessibilityPart(title: String, lane: LoadRingReading) -> String { - if let ratio = lane.ratio, let band = lane.ratioBand { - return "\(title): \(lane.label), \(LoadScale.ratioText(ratio, band: band))" - } - return "\(title): \(lane.label), \(String(localized: "provisional seven-day amount"))" - } - - // MARK: Layers - - /// Light pooling in the middle, in whichever verdicts exist. Two lanes means two washes, which is - /// what gives the centre its colour without printing a third, invented status there. - private var glow: some View { - ZStack { - if let strength { - Circle().fill(RadialGradient( - colors: [strength.color.opacity(0.26), strength.color.opacity(0)], - center: .center, startRadius: 0, endRadius: diameter * 0.38)) - } - if let cardio { - Circle().fill(RadialGradient( - colors: [cardio.color.opacity(0.22), cardio.color.opacity(0)], - center: .center, startRadius: 0, endRadius: diameter * 0.28)) - } - } - .padding(labelInset + outerWidth) - } - - /// Rail, glow and lit arc per lane. The tint is the lane's own personal relative-load reading. - private var rings: some View { - ZStack { - laneArcs(lane: strength, fraction: shownStrength, width: outerWidth, padding: labelInset) - laneArcs(lane: cardio, fraction: shownCardio, width: innerWidth, padding: innerPadding) - } - } - - private func laneArcs(lane: LoadRingReading?, fraction: Double, - width: CGFloat, padding: CGFloat) -> some View { - ZStack { - arc(from: 0, to: 1, width: width) - .stroke(StrandPalette.surfaceInset, - style: StrokeStyle(lineWidth: width, lineCap: .round, - dash: lane == nil ? [2, 5] : [])) - if let lane { - arc(from: 0, to: 1, width: width) - .stroke(lane.color.opacity(0.28), - style: StrokeStyle(lineWidth: width, lineCap: .round)) - arc(from: 0, to: fraction, width: width) - .stroke(lane.color, - style: StrokeStyle(lineWidth: width, lineCap: .round)) - .blur(radius: 8) - .opacity(0.7) - arc(from: 0, to: fraction, width: width) - .stroke(lane.color, - style: StrokeStyle(lineWidth: width, lineCap: .round)) - } - } - .padding(padding) - } - - /// Each lane's position, carrying that lane's own symbol — the thing that says which arc is which. - private var knobs: some View { - ZStack { - if let strength { - knob(color: strength.color, symbol: "figure.strengthtraining.traditional", - fraction: shownStrength, radius: outerRadius, size: outerWidth + 6) - } - if let cardio { - knob(color: cardio.color, symbol: "heart.fill", - fraction: shownCardio, radius: innerRadius, size: innerWidth + 6) - } - } - } - - private func knob(color: Color, symbol: String, fraction: Double, - radius: CGFloat, size: CGFloat) -> some View { - ZStack { - Circle().fill(StrandPalette.onDarkPrimary) - Circle().fill(color.gradient).padding(2.5) - Image(systemName: symbol) - .font(StrandFont.rounded(size * 0.4, weight: .bold)) - .foregroundStyle(StrandPalette.onDarkPrimary) - } - .frame(width: size, height: size) - .shadow(color: color.opacity(0.75), radius: 7) - .position(point(radians(fraction), radius: radius)) - .frame(width: diameter, height: diameter) - } - - /// Both verdicts, one per line, each next to its lane's symbol. Two lines rather than one word: - /// there is no combined status to print, and inventing one is exactly what this page must not do. - /// - /// The ratio deliberately does NOT repeat here. The opening between the arcs is barely wider than - /// "Восстановление", and a second line under each word pushed the block into the rings; the rows - /// beneath the instrument carry every ratio already. - private var centre: some View { - VStack(alignment: .leading, spacing: 6) { - centreRow(symbol: "figure.strengthtraining.traditional", lane: strength) - centreRow(symbol: "heart.fill", lane: cardio) - } - // The 240° arc is open at the bottom, so the instrument's optical centre sits above its - // geometric one; without the nudge the block reads as having slipped downwards. - .offset(y: -diameter * 0.04) - } - - private func centreRow(symbol: String, lane: LoadRingReading?) -> some View { - // No trailing spacer: each row sizes to its own content so the pair is CENTRED in the opening - // rather than pinned to its left edge. - HStack(spacing: 6) { - Image(systemName: symbol) - .font(StrandFont.rounded(13, weight: .bold)) - .foregroundStyle(lane?.color ?? StrandPalette.textTertiary) - .frame(width: 16) - .trainingSymbolBounce(trigger: bounce) - Text(lane?.label ?? "—") - .font(StrandFont.rounded(15, weight: .bold)) - .foregroundStyle(StrandPalette.textPrimary) - .lineLimit(1) - .minimumScaleFactor(0.5) - .frame(maxWidth: diameter * 0.46, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) - } - } - - // MARK: Geometry - - private func arc(from: Double, to: Double, width: CGFloat) -> RecoveryArc { - RecoveryArc(startAngle: .degrees(startDegrees + spanDegrees * from), - spanDegrees: spanDegrees * max(to - from, 0), fraction: 1, lineWidth: width) - } - - private func radians(_ fraction: Double) -> Double { - (startDegrees + spanDegrees * fraction) * .pi / 180 - } - - private func point(_ angle: Double, radius: CGFloat) -> CGPoint { - CGPoint(x: diameter / 2 + radius * cos(angle), y: diameter / 2 + radius * sin(angle)) - } } /// A number that rolls to its value, in the wearer's locale. (The Liquid `CountUpNumber` always prints @@ -418,85 +113,6 @@ struct TrainingCountUp: View, Animatable { // MARK: - Surfaces -/// The hero's surface: a card lit from above by the two lanes' status colours — strength from the -/// left, cardio from the right — so the page's answer is the first thing the eye picks up. -struct TrainingHeroSurface: View { - let leading: Color - let trailing: Color - - var body: some View { - let shape = RoundedRectangle(cornerRadius: NoopMetrics.cardRadius, style: .continuous) - ZStack { - shape.fill(StrandPalette.surfaceRaised) - StatusGlow(leading: leading, trailing: trailing).clipShape(shape) - } - .overlay(shape.strokeBorder(StrandPalette.hairline, lineWidth: 1)) - .shadow(color: leading.opacity(0.16), radius: 18, x: -8, y: 10) - .shadow(color: trailing.opacity(0.16), radius: 18, x: 8, y: 10) - } -} - -/// Two soft pools of colour. A slowly drifting mesh gradient where the OS has one; two radial -/// gradients before that. Posed still under Reduce Motion, Low Power, quiet motion and off-screen. -private struct StatusGlow: View { - let leading: Color - let trailing: Color - - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.dashboardIsActive) private var dashboardIsActive - @Environment(\.scenePhase) private var scenePhase - @ObservedObject private var motion = NoopMotionState.shared - @State private var isVisible = true - - var body: some View { - content.dashboardAnimationVisibility($isVisible) - } - - @ViewBuilder private var content: some View { - if #available(iOS 18.0, macOS 15.0, *) { - if isVisible && dashboardIsActive && scenePhase == .active && !motion.poseStill(reduceMotion) { - TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in - MeshGlow(leading: leading, trailing: trailing, - phase: timeline.date.timeIntervalSinceReferenceDate) - } - } else { - MeshGlow(leading: leading, trailing: trailing, phase: 0) - } - } else { - ZStack { - RadialGradient(colors: [leading.opacity(0.5), leading.opacity(0)], - center: .topLeading, startRadius: 0, endRadius: 300) - RadialGradient(colors: [trailing.opacity(0.5), trailing.opacity(0)], - center: .topTrailing, startRadius: 0, endRadius: 300) - } - } - } -} - -@available(iOS 18.0, macOS 15.0, *) -private struct MeshGlow: View { - let leading: Color - let trailing: Color - let phase: Double - - var body: some View { - let drift = Float(sin(phase * 0.42) * 0.08) - let lift = Float(cos(phase * 0.31) * 0.06) - let clear = StrandPalette.surfaceRaised.opacity(0) - let points: [SIMD2] = [ - [0, 0], [0.5, 0], [1, 0], - [0, 0.42 + lift], [0.5 + drift, 0.38 - lift], [1, 0.42 - lift], - [0, 1], [0.5, 1], [1, 1], - ] - let colors: [Color] = [ - leading.opacity(0.58), clear, trailing.opacity(0.58), - leading.opacity(0.24), clear, trailing.opacity(0.24), - clear, clear, clear, - ] - return MeshGradient(width: 3, height: 3, points: points, colors: colors) - } -} - /// A card washed in one colour, for the page's statements — the advice, the warning. An optional /// large symbol sits in the corner as a watermark. struct TrainingWashCard: View { @@ -585,41 +201,6 @@ struct StatusBadge: View { } } -// MARK: - Zone legend - -/// The four zones of the dial, one dot each. -struct LoadZoneLegend: View { - private let statuses: [TrainingStatus] = [.detraining, .maintaining, .productive, .overreaching] - - var body: some View { - ViewThatFits(in: .horizontal) { - HStack(spacing: NoopMetrics.space3) { - ForEach(statuses, id: \.self) { legendItem($0) } - } - .fixedSize(horizontal: true, vertical: false) - - LazyVGrid(columns: [GridItem(.flexible(), alignment: .leading), - GridItem(.flexible(), alignment: .leading)], - alignment: .leading, spacing: NoopMetrics.space2) { - ForEach(statuses, id: \.self) { legendItem($0) } - } - } - .frame(maxWidth: .infinity) - .accessibilityElement(children: .combine) - } - - private func legendItem(_ status: TrainingStatus) -> some View { - HStack(spacing: 4) { - Circle().fill(status.color.gradient).frame(width: 9, height: 9) - Text(status.label) - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textSecondary) - .lineLimit(1) - .minimumScaleFactor(0.8) - } - } -} - // MARK: - Ratio chart /// Eight weeks of one lane's ratio, the line coloured by the zone it is in, over faint zone bands.