diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/CardioSession.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/CardioSession.swift index a8d09fe245..47694194d8 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/CardioSession.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/CardioSession.swift @@ -565,3 +565,81 @@ public enum CardioSession { } } } + +/// One sport's part of a week's measured cardio load. +public struct CardioSportLoadShare: Equatable, Sendable { + public let sport: String + public let modality: CardioModality + public let load: Double + /// Of the week's measured load, 0…1. + public let share: Double +} + +extension CardioSession { + private static func inWeek(containing anchorDay: String, + _ sessions: [CardioSessionMetrics]) -> [CardioSessionMetrics] { + guard let monday = WeeklyDigestEngine.mondayOfWeek(containing: anchorDay) else { return [] } + let sunday = WeeklyDigestEngine.addDays(monday, 6) + return sessions.filter { $0.day >= monday && $0.day <= sunday } + } + + /// Each sport's share of the week's measured load, largest first. Sessions without a measured load + /// are left out of both sides, so a share never mixes measured and unmeasured work. + public static func loadShareBySport(inWeekContaining anchorDay: String, + sessions: [CardioSessionMetrics]) -> [CardioSportLoadShare] { + var bySport: [String: (modality: CardioModality, load: Double)] = [:] + for session in inWeek(containing: anchorDay, sessions) { + guard let load = session.cardioLoad, load.isFinite, load > 0 else { continue } + bySport[session.sport, default: (session.modality, 0)].load += load + } + let total = bySport.values.reduce(0) { $0 + $1.load } + guard total > 0 else { return [] } + return bySport.map { CardioSportLoadShare(sport: $0.key, modality: $0.value.modality, + load: $0.value.load, share: $0.value.load / total) } + .sorted { $0.load != $1.load ? $0.load > $1.load : $0.sport < $1.sport } + } + + /// One sport's pace over the week: total moving time over total distance, in seconds per kilometre, + /// from the sessions that recorded both. Nil when none did. + public static func weeklyPaceSecPerKm(sport: String, inWeekContaining anchorDay: String, + sessions: [CardioSessionMetrics]) -> Double? { + var seconds = 0.0, metres = 0.0 + for session in inWeek(containing: anchorDay, sessions) + where session.sport.caseInsensitiveCompare(sport) == .orderedSame { + guard let duration = session.durationS, duration > 0, + let distance = session.distanceM, distance > 0 else { continue } + seconds += duration + metres += distance + } + guard metres > 0 else { return nil } + return seconds / (metres / 1000) + } + + /// The week's average heart rate, weighted by each session's duration, over sessions carrying both. + public static func weeklyAverageHr(inWeekContaining anchorDay: String, + sessions: [CardioSessionMetrics]) -> Double? { + var beats = 0.0, seconds = 0.0 + for session in inWeek(containing: anchorDay, sessions) { + guard let hr = session.avgHr, hr > 0, let duration = session.durationS, duration > 0 else { continue } + beats += Double(hr) * duration + seconds += duration + } + return seconds > 0 ? beats / seconds : nil + } + + /// The median weekly average heart rate over the preceding `weeks` weeks that have one. Nil below + /// three such weeks, the same floor the usual weekly minutes use. + public static func typicalWeeklyAverageHr(_ sessions: [CardioSessionMetrics], + endingBefore anchorDay: String, + weeks: Int = 8) -> Double? { + guard let thisMonday = WeeklyDigestEngine.mondayOfWeek(containing: anchorDay) else { return nil } + var values: [Double] = [] + var monday = WeeklyDigestEngine.addDays(thisMonday, -7) + for _ in 0..= 3 else { return nil } + return StrengthSession.percentile(values.sorted(), 0.5) + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CardioSessionTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CardioSessionTests.swift index b097c63ce7..041dbaa329 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CardioSessionTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/CardioSessionTests.swift @@ -219,3 +219,53 @@ final class CardioModalityUnitTests: XCTestCase { XCTAssertEqual(CardioModality.cycling.readout, .speed) } } + +extension CardioSessionTests { + // MARK: - Week summaries for the load screen + + private func week(_ rows: [WorkoutRow], loads: [Int: Double] = [:]) -> [CardioSessionMetrics] { + CardioSession.sessions(rows, tzOffsetSeconds: 0, cardioLoadByStart: loads) + } + + func testLoadShareCountsOnlyMeasuredLoadInTheWeek() { + let run1 = Self.ts("2026-09-14"), run2 = Self.ts("2026-09-16"), ride = Self.ts("2026-09-15") + let unmeasured = Self.ts("2026-09-17"), lastWeek = Self.ts("2026-09-10") + let sessions = week([row("Running", at: run1, km: 10), row("Running", at: run2, km: 8), + row("Cycling", at: ride, km: 30), row("Rowing", at: unmeasured), + row("Cycling", at: lastWeek, km: 40)], + loads: [run1: 90, run2: 60, ride: 50, lastWeek: 500]) + let shares = CardioSession.loadShareBySport(inWeekContaining: "2026-09-17", sessions: sessions) + XCTAssertEqual(shares.map(\.sport), ["Running", "Cycling"]) + XCTAssertEqual(shares.map(\.load), [150, 50]) + XCTAssertEqual(shares.map(\.share), [0.75, 0.25]) + XCTAssertTrue(CardioSession.loadShareBySport(inWeekContaining: "2026-09-17", + sessions: week([row("Rowing", at: unmeasured)])).isEmpty) + } + + func testWeeklyPaceIsTotalTimeOverTotalDistanceForThatSport() { + let sessions = week([row("Running", at: Self.ts("2026-09-14"), minutes: 50, km: 10), + row("Running", at: Self.ts("2026-09-16"), minutes: 30, km: 5), + row("Running", at: Self.ts("2026-09-17"), minutes: 40, km: nil), + row("Swimming", at: Self.ts("2026-09-15"), minutes: 40, km: 2)]) + let run = CardioSession.weeklyPaceSecPerKm(sport: "running", inWeekContaining: "2026-09-17", sessions: sessions) + XCTAssertEqual(try XCTUnwrap(run), 80 * 60 / 15, accuracy: 1e-9, "the run without a distance is left out") + let swim = CardioSession.weeklyPaceSecPerKm(sport: "Swimming", inWeekContaining: "2026-09-17", sessions: sessions) + XCTAssertEqual(try XCTUnwrap(swim) / 10, 120, accuracy: 1e-9, "2:00 per 100 m") + XCTAssertNil(CardioSession.weeklyPaceSecPerKm(sport: "Cycling", inWeekContaining: "2026-09-17", sessions: sessions)) + } + + func testWeeklyHeartRateIsWeightedByDurationAndComparedWithEarlierWeeks() { + var rows = [row("Running", at: Self.ts("2026-09-14"), minutes: 60, avgHr: 150), + row("Walking", at: Self.ts("2026-09-15"), minutes: 30, avgHr: 120), + row("Cycling", at: Self.ts("2026-09-16"), minutes: 45, avgHr: nil)] + let current = CardioSession.weeklyAverageHr(inWeekContaining: "2026-09-16", sessions: week(rows)) + XCTAssertEqual(try XCTUnwrap(current), 140, accuracy: 1e-9) + XCTAssertNil(CardioSession.typicalWeeklyAverageHr(week(rows), endingBefore: "2026-09-16")) + + for (day, hr) in [("2026-09-07", 130), ("2026-08-31", 136), ("2026-08-24", 132)] { + rows.append(row("Running", at: Self.ts(day), minutes: 45, avgHr: hr)) + } + let typical = CardioSession.typicalWeeklyAverageHr(week(rows), endingBefore: "2026-09-16") + XCTAssertEqual(try XCTUnwrap(typical), 132, accuracy: 1e-9) + } +} 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)") + } + } + } +} diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 613f4b6325..57e37b8661 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -435461,6 +435461,1286 @@ } } } + }, + "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" : "對比平常" + } + } + } + }, + "%lld %% of cardio load" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld %% der Cardio-Last" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld %% of cardio load" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld %% de la carga cardio" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld %% de la charge cardio" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld %% del carico cardio" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld %% obciążenia cardio" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld %% da carga cardio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld %% кардионагрузки" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "占有氧负荷 %lld%%" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "佔有氧負荷 %lld%%" + } + } + } + }, + "%lld of %lld sessions complete" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld von %2$lld Einheiten vollständig" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld of %2$lld sessions complete" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld de %2$lld sesiones completas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld séances complètes sur %2$lld" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld sessioni complete su %2$lld" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld z %2$lld sesji kompletnych" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld de %2$lld sessões completas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld из %2$lld тренировок полные" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$lld 次训练中 %1$lld 次完整" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%2$lld 次訓練中 %1$lld 次完整" + } + } + } + }, + "How your cardio load is calculated" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wie deine Cardio-Last berechnet wird" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "How your cardio load is calculated" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cómo se calcula tu carga cardio" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comment votre charge cardio est calculée" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Come viene calcolato il tuo carico cardio" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jak liczone jest Twoje obciążenie cardio" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Como é calculada a tua carga cardio" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Как рассчитывается кардионагрузка" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "有氧负荷的计算方式" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "有氧負荷的計算方式" + } + } + } + }, + "Most used activity" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Häufigste Aktivität" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Most used activity" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actividad más frecuente" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activité la plus pratiquée" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Attività più frequente" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Najczęstsza aktywność" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atividade mais frequente" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Основная активность" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最常进行的运动" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "最常進行的運動" + } + } + } + }, + "What counts as a best" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Was als Bestwert zählt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "What counts as a best" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Qué cuenta como marca personal" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ce qui compte comme record" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cosa conta come record" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Co liczy się jako rekord" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "O que conta como recorde" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Что считается рекордом" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "什么算作最佳成绩" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "什麼算作最佳成績" + } + } + } + }, + "Where the zone split comes from" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Woher die Zonenverteilung stammt" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Where the zone split comes from" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "De dónde sale el reparto por zonas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "D’où vient la répartition par zones" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Da dove viene la ripartizione per zone" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skąd pochodzi podział na strefy" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "De onde vem a divisão por zonas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Откуда берётся распределение по зонам" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "心率区间分布的来源" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "心率區間分佈的來源" + } + } + } + }, + "usual %lld bpm" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "üblich %lld bpm" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "usual %lld bpm" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "habitual %lld ppm" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "habituel %lld bpm" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "abituale %lld bpm" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "zwykle %lld bpm" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "habitual %lld bpm" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "обычно %lld уд/мин" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "平常 %lld 次/分" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "平常 %lld 次/分" + } + } + } } }, "version" : "1.0" diff --git a/Strand/Screens/CardioModel.swift b/Strand/Screens/CardioModel.swift index 0697d9ce10..7c0d225a4d 100644 --- a/Strand/Screens/CardioModel.swift +++ b/Strand/Screens/CardioModel.swift @@ -58,7 +58,17 @@ 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] = [] + /// The day the selected week is read through, for the load chart. + @Published private(set) var laneReadingDay = Repository.localDayKey(Date()) + /// Each sport's share of the week's measured load, largest first. + @Published private(set) var loadShares: [CardioSportLoadShare] = [] + /// Moving time over distance for the week's biggest sport, in seconds per kilometre. + @Published private(set) var topSportPace: Double? + @Published private(set) var weekAverageHr: Double? + @Published private(set) var typicalAverageHr: Double? @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 +86,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() + @Published private(set) var laneSeries: TrainingLoadLanes.CardioSeries? // The selected sport @Published private(set) var sportHistory: [CardioSessionMetrics] = [] @@ -100,7 +115,13 @@ 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 laneDay: String + let shares: [CardioSportLoadShare] + let topSportPace: Double? + let averageHr: Double? + let typicalAverageHr: Double? let zones: CardioZoneSplit? } @@ -115,6 +136,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 +149,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 +211,35 @@ 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 shares = CardioSession.loadShareBySport(inWeekContaining: anchor, sessions: all) + 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), + laneDay: laneDay, + shares: shares, + topSportPace: shares.first.flatMap { + CardioSession.weeklyPaceSecPerKm(sport: $0.sport, inWeekContaining: anchor, + sessions: all) + }, + averageHr: CardioSession.weeklyAverageHr(inWeekContaining: anchor, sessions: all), + typicalAverageHr: CardioSession.typicalWeeklyAverageHr(all, endingBefore: anchor), zones: zones) }.value @@ -219,7 +267,13 @@ final class CardioModel: ObservableObject { private func apply(_ bundle: WeekBundle) { week = bundle.week typicalMinutes = bundle.typical - load = bundle.load + lane = bundle.lane + laneRatios = bundle.laneRatios + laneReadingDay = bundle.laneDay + loadShares = bundle.shares + topSportPace = bundle.topSportPace + weekAverageHr = bundle.averageHr + typicalAverageHr = bundle.typicalAverageHr zoneSplit = bundle.zones } @@ -282,14 +336,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..484102b8f2 100644 --- a/Strand/Screens/CardioView.swift +++ b/Strand/Screens/CardioView.swift @@ -38,7 +38,6 @@ struct CardioView: View { override: distanceSystemRaw) } - @State private var infoTopic: InfoTopic? @State private var openDetail: DetailTarget? struct DetailTarget: Identifiable, Equatable { @@ -56,12 +55,17 @@ struct CardioView: View { } else if model.sessions.isEmpty { emptyState } else { - thisWeek - intensityCard + weekControl + loadHero + weekFigures + loadChart + intensityCard.id("intensity") + activityTiles(proxy) sportMix - sportProgress + sportProgress.id("progress") bestsCard recentSessions + explainers } } .padding(NoopMetrics.screenPadding) @@ -73,7 +77,6 @@ struct CardioView: View { if let context = coachContext { CoachCardButton(context: context) } } } - .sheet(item: $infoTopic) { topic in infoSheet(topic) } .sheet(item: $openDetail) { target in NavigationStack { if let row = model.sessions.first(where: { @@ -115,41 +118,59 @@ struct CardioView: View { // MARK: - This week - private var thisWeek: some View { - VStack(alignment: .leading, spacing: NoopMetrics.gap) { - weekNavBar - NoopCard { - VStack(alignment: .leading, spacing: NoopMetrics.space2) { - LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 3), - spacing: 10) { - tile(icon: "figure.run", label: String(localized: "Sessions"), - value: "\(model.week.sessionCount)", tint: DomainTheme.effort.color) - tile(icon: "clock.fill", label: String(localized: "Moving time"), - value: durationText(model.week.minutes * 60), - tint: DomainTheme.effort.color, - caption: usualMinutesText) - tile(icon: "point.topleft.down.to.point.bottomright.curvepath", - label: String(localized: "Distance"), - value: model.week.distanceM > 0 - ? UnitFormatter.distanceFromMeters(model.week.distanceM, system: units) - : "—", - tint: StrandPalette.metricCyan, - caption: distanceCoverageText) - loadTile - tile(icon: "flame.fill", label: String(localized: "Calories"), - value: model.week.energyKcal > 0 - ? grouped(model.week.energyKcal) : "—", - tint: StrandPalette.metricAmber, - caption: model.week.energyKcal > 0 ? "kcal" : nil) - tile(icon: "heart.fill", label: String(localized: "Cardio load"), - value: model.week.effort.map { String(format: "%.0f", $0) } ?? "—", - tint: StrandPalette.effortColor, - caption: String(localized: "this week")) - } - if let typical = model.typicalMinutes, model.week.minutes > 0 { - weekAgainstUsual(typical) - } - } + private var weekControl: some View { + TrainingWeekControl(overline: String(localized: "Endurance"), rangeText: weekRangeText, + canGoBack: model.weekOffset > model.minWeekOffset, + canGoForward: model.weekOffset < 0, + step: { delta in step(delta) }, + ranges: CardioModel.HistoryRange.allCases, + selectedRange: $model.range, + rangeLabel: { $0.label }) + } + + /// The week's cardio load against the wearer's usual, read exactly as Training Load reads it. + private var loadHero: some View { + let lane = model.lane + return LoadHeroCard(lane: .cardio, title: String(localized: "Cardio load"), + percent: lane?.trend?.percentChange, state: LoadPillState.of(lane), + figure: lane.map(trimpText), trend: model.laneRatios.compactMap(\.cardio), + coverage: lane.flatMap { $0.possibleCount > 0 + ? String(localized: "\($0.measuredCount) of \($0.possibleCount) sessions complete") + : nil }, + caveat: loadCaveat) + } + + private func trimpText(_ lane: TrainingLoadModel.Lane) -> String { + let total = String(localized: "\(Int(lane.sevenDayTotal.rounded())) TRIMP") + return lane.isLowerBound ? String(localized: "at least \(total)") : total + } + + private var loadCaveat: String? { + guard let lane = model.lane, lane.possibleCount > 0 else { return nil } + if model.laneSeries?.measured == false { + return String(localized: "No usable heart-rate trace in this window, so cardiovascular load is not estimated") + } + guard Double(lane.measuredCount) / Double(lane.possibleCount) < TrainingLoad.trustedRatedShare else { return nil } + return String(localized: "Only \(lane.measuredCount) of \(lane.possibleCount) sessions are complete; the measured total is a lower bound") + } + + private var weekFigures: some View { + VStack(spacing: NoopMetrics.space2) { + KPIStrip(lane: .cardio, items: [ + KPIItem(id: "sessions", icon: "figure.run", value: "\(model.week.sessionCount)", + label: String(localized: "Sessions")), + KPIItem(id: "time", icon: "clock.fill", value: durationText(model.week.minutes * 60), + label: String(localized: "Moving time"), caption: usualMinutesText), + KPIItem(id: "distance", icon: "point.topleft.down.to.point.bottomright.curvepath", + value: model.week.distanceM > 0 + ? UnitFormatter.distanceFromMeters(model.week.distanceM, system: units) : "—", + label: String(localized: "Distance"), caption: distanceCoverageText), + KPIItem(id: "energy", icon: "flame.fill", + value: model.week.energyKcal > 0 ? grouped(model.week.energyKcal) : "—", + label: String(localized: "Calories"), caption: model.week.energyKcal > 0 ? "kcal" : nil), + ]) + if let typical = model.typicalMinutes, model.week.minutes > 0 { + NoopCard(padding: NoopMetrics.space3) { weekAgainstUsual(typical) } } } } @@ -158,21 +179,18 @@ struct CardioView: View { /// Strength screen uses for muscle volume, so "the shaded part is normal for you" is learned once. private func weekAgainstUsual(_ typical: ClosedRange) -> some View { let scale = max(model.week.minutes, typical.upperBound, 1) - return VStack(alignment: .leading, spacing: 5) { - Divider().overlay(StrandPalette.hairline) - HStack(spacing: 10) { - Text("vs your usual") - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .frame(width: 92, alignment: .leading) - TypicalRangeBar(value: model.week.minutes / scale, - typical: (typical.lowerBound / scale)...(typical.upperBound / scale), - color: DomainTheme.effort.color, height: 8) - Text(String(localized: "\(Int(typical.lowerBound.rounded()))–\(Int(typical.upperBound.rounded())) min")) - .font(StrandFont.caption) - .foregroundStyle(StrandPalette.textTertiary) - .lineLimit(1) - } + return HStack(spacing: 10) { + Text("vs your usual") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .frame(width: 92, alignment: .leading) + TypicalRangeBar(value: model.week.minutes / scale, + typical: (typical.lowerBound / scale)...(typical.upperBound / scale), + color: TrainingLane.cardio.color, height: 8) + Text(String(localized: "\(Int(typical.lowerBound.rounded()))–\(Int(typical.upperBound.rounded())) min")) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .lineLimit(1) } } @@ -187,144 +205,73 @@ struct CardioView: View { return String(localized: "\(model.week.sessionsWithDistance) of \(model.week.sessionCount) sessions") } - @ViewBuilder - private var loadTile: some View { - let load = model.load - tile(icon: "chart.bar.fill", - label: String(localized: "Load trend"), - value: load.map { signedPercent($0.percentChange) } ?? "—", - tint: load.map { loadTint($0.percentChange) } ?? StrandPalette.textTertiary, - caption: load.map { loadCaption($0.percentChange) } ?? String(localized: "needs 2 weeks"), - info: .cardioLoad) - } - - private func signedPercent(_ value: Double) -> String { - let magnitude = Int(abs(value).rounded()) - // A change that rounds to zero has no direction; "+0 %" or "−0 %" would imply one. - guard magnitude > 0 else { return "0 %" } - return "\(value > 0 ? "+" : "−")\(magnitude) %" + private var loadChart: some View { + LoadHistoryChart(lane: .cardio, title: String(localized: "Cardio load"), unit: "TRIMP", + byDay: model.laneSeries?.byDay ?? [:], unknownDays: model.laneSeries?.unknownDays ?? [], + readingDay: model.laneReadingDay, + usualWeek: model.lane?.relative.personalRange.map { $0.usualLowerBound...$0.usualUpperBound }) } - /// A load change is context, not a grade. A larger week can be intentional or excessive; Charge - /// and the athlete's own perception are what distinguish those cases. - private func loadTint(_ percent: Double) -> Color { - abs(percent) < 15 ? StrandPalette.textSecondary : StrandPalette.metricCyan - } - - private func loadCaption(_ percent: Double) -> String { - if percent >= 15 { return String(localized: "above your usual") } - if percent <= -15 { return String(localized: "below your usual") } - return String(localized: "about your usual") + private func step(_ delta: Int) { + Task { await model.stepWeek(delta, repo: repo) } } - private var weekNavBar: some View { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("Endurance").strandOverline() - Text("This week").font(StrandFont.title2) - .foregroundStyle(StrandPalette.textPrimary) - } - Spacer(minLength: 8) - rangePicker - HStack(spacing: 10) { - Button { step(-1) } label: { Image(systemName: "chevron.left") } - .disabled(model.weekOffset <= model.minWeekOffset) - Text(weekRangeText) - .font(StrandFont.footnote) - .foregroundStyle(StrandPalette.textSecondary) - .monospacedDigit() - Button { step(1) } label: { Image(systemName: "chevron.right") } - .disabled(model.weekOffset >= 0) + // MARK: - Activity and heart rate + + @ViewBuilder private func activityTiles(_ proxy: ScrollViewProxy) -> some View { + let top = model.loadShares.first + if top != nil || model.weekAverageHr != nil { + LazyVGrid(columns: [GridItem(.flexible(), spacing: NoopMetrics.gap), + GridItem(.flexible(), spacing: NoopMetrics.gap)], + spacing: NoopMetrics.gap) { + if let top { + SummaryTile(symbol: CardioView.symbol(for: top.modality), tint: TrainingLane.cardio.color, + title: String(localized: "Most used activity"), headline: sportName(top.sport), + detail: activityDetail(top), + action: { + Task { await model.select(top.sport) } + withAnimation { proxy.scrollTo("progress", anchor: .top) } + }) { EmptyView() } + } + if let hr = model.weekAverageHr { + SummaryTile(symbol: "heart.fill", tint: StrandPalette.statusCritical, + title: String(localized: "Average heart rate"), + headline: String(localized: "\(Int(hr.rounded())) bpm"), + detail: model.typicalAverageHr.map { + String(localized: "usual \(Int($0.rounded())) bpm") + }, + action: { withAnimation { proxy.scrollTo("intensity", anchor: .top) } }) { + EmptyView() + } + } } - .buttonStyle(.plain) - .foregroundStyle(StrandPalette.accent) } } - private var rangePicker: some View { - Menu { - ForEach(CardioModel.HistoryRange.allCases) { option in - Button { - model.range = option - } label: { - if model.range == option { - Label(option.label, systemImage: "checkmark") - } else { - Text(option.label) - } + /// The sport's share of the week's measured load, and its pace in the unit that sport is read in. + private func activityDetail(_ share: CardioSportLoadShare) -> String { + var parts = [String(localized: "\(Int((share.share * 100).rounded())) % of cardio load")] + if let pace = model.topSportPace { + switch share.modality.readout { + case .pace: parts.append(paceText(secPerKm: pace, modality: share.modality)) + case .speed: + if let speed = UnitFormatter.speedFromKilometersPerHour(3600 / pace, system: units) { + parts.append(speed) } + case .none: break } - } label: { - HStack(spacing: 4) { - Image(systemName: "calendar") - .font(.system(size: 10, weight: .semibold)) - Text(model.range.label).font(StrandFont.caption) - } - .foregroundStyle(StrandPalette.textSecondary) - .padding(.horizontal, 9) - .padding(.vertical, 5) - .background(StrandPalette.surfaceInset, in: Capsule()) } - .buttonStyle(.plain) - .accessibilityLabel(String(localized: "History window")) - } - - private func step(_ delta: Int) { - Task { await model.stepWeek(delta, repo: repo) } + return parts.joined(separator: " · ") } - /// One tile of the weekly grid — compact and fixed-height, the twin of the Strength screen's. See - /// that one for why the shared `TodayMetricTile` is not used at this size. - private func tile(icon: String, label: String, value: String, tint: Color, - caption: String? = nil, info: InfoTopic? = nil) -> some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 0) { - ZStack { - Circle().fill(tint.opacity(0.13)) - Image(systemName: icon) - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(tint) - } - .frame(width: 22, height: 22) - .accessibilityHidden(true) - Spacer(minLength: 0) - if let info { - Button { infoTopic = info } label: { - Image(systemName: "info.circle") - .font(.system(size: 11)) - .foregroundStyle(StrandPalette.textTertiary) - } - .buttonStyle(.plain) - .accessibilityLabel("What this means") - } - } - Spacer(minLength: 0) - Text(value) - .font(StrandFont.number(26)) - .foregroundStyle(StrandPalette.textPrimary) - .lineLimit(1) - .minimumScaleFactor(0.55) - // `subhead`, not `caption`: on iOS the scale runs subhead (13) → caption (12) → footnote - // (11), and a tile label set in caption reads as a footnote to a number that is the point - // of the tile. The caption line below stays a step smaller, which is what keeps the two - // apart now that the label has grown. - Text(label) - .font(StrandFont.subhead) - .foregroundStyle(StrandPalette.textSecondary) - .lineLimit(1) - .minimumScaleFactor(0.65) - Text(caption ?? " ") - .font(StrandFont.caption) - .foregroundStyle(tint) - .lineLimit(1) - .minimumScaleFactor(0.7) + static func symbol(for modality: CardioModality) -> String { + switch modality { + case .foot: return "figure.run" + case .cycling: return "bicycle" + case .swimming: return "figure.pool.swim" + case .rowing: return "figure.rower" + default: return "figure.mixed.cardio" } - .padding(.horizontal, 10) - .padding(.vertical, 9) - .frame(maxWidth: .infinity, minHeight: 112, maxHeight: 112, alignment: .leading) - .background(TodayCardSurface(tint: tint, cornerRadius: NoopMetrics.groupedRadius)) - .accessibilityElement(children: .combine) - .accessibilityLabel("\(label): \(value)\(caption.map { ", " + $0 } ?? "")") } // MARK: - Intensity distribution @@ -342,7 +289,7 @@ struct CardioView: View { VStack(alignment: .leading, spacing: NoopMetrics.gap) { SectionHeader("Intensity", overline: "Time in zone", trailing: durationText(total * 60)) - NoopCard(tint: StrandPalette.effortColor) { + NoopCard(tint: TrainingLane.cardio.color) { VStack(alignment: .leading, spacing: 12) { GeometryReader { geo in // Five segments leave four 2-point gaps. Subtract them before distributing @@ -366,10 +313,6 @@ struct CardioView: View { zoneStat(index + 1, minutes: minutes[index], total: total) } } - Text(zoneProvenanceText(split)) - .font(StrandFont.footnote) - .foregroundStyle(StrandPalette.textTertiary) - .fixedSize(horizontal: false, vertical: true) } } } @@ -650,18 +593,7 @@ struct CardioView: View { private var bestsCard: some View { if !model.bests.isEmpty, let sport = model.selectedSport { VStack(alignment: .leading, spacing: NoopMetrics.gap) { - HStack { - SectionHeader("Your bests", - overline: LocalizedStringKey(sportName(sport))) - Spacer(minLength: 8) - Button { infoTopic = .bests } label: { - Image(systemName: "info.circle") - .font(.system(size: 11)) - .foregroundStyle(StrandPalette.textTertiary) - } - .buttonStyle(.plain) - .accessibilityLabel("What this means") - } + SectionHeader("Your bests", overline: LocalizedStringKey(sportName(sport))) NoopCard { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { @@ -810,13 +742,7 @@ struct CardioView: View { } private func sportSymbol(_ session: CardioSessionMetrics) -> String { - switch session.modality { - case .foot: return "figure.run" - case .cycling: return "bicycle" - case .swimming: return "figure.pool.swim" - case .rowing: return "figure.rower" - default: return "figure.mixed.cardio" - } + CardioView.symbol(for: session.modality) } /// The stored row behind a derived session, for the existing detail screen. @@ -852,47 +778,24 @@ struct CardioView: View { return "\(start.formatted(format)) – \(end.formatted(format))" } - // MARK: - Info - - enum InfoTopic: String, Identifiable { - case cardioLoad, bests - var id: String { rawValue } - } - - private func infoSheet(_ topic: InfoTopic) -> some View { - NavigationStack { - ScrollView { - VStack(alignment: .leading, spacing: NoopMetrics.space3) { - Text(infoTitle(topic)) - .font(StrandFont.title2).foregroundStyle(StrandPalette.textPrimary) - Text(infoBody(topic)) - .font(StrandFont.body).foregroundStyle(StrandPalette.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(NoopMetrics.screenPadding) - } - .background(StrandPalette.surfaceBase.ignoresSafeArea()) - .toolbar { - ToolbarItem(placement: .confirmationAction) { Button("Done") { infoTopic = nil } } - } - } - } + // MARK: - How it works - private func infoTitle(_ topic: InfoTopic) -> String { - switch topic { - case .cardioLoad: return String(localized: "Cardio load") - case .bests: return String(localized: "Your bests") - } - } - - private func infoBody(_ topic: InfoTopic) -> String { - switch topic { - case .cardioLoad: - return String(localized: "How much cardiovascular work the last 7 days asked of you, against your own level over the last 28 days. It is a percentage, not a score: +18 % means the recent week ran about a fifth above your usual.\n\nThe underlying signal is additive TRIMP, derived from heart rate and time in intensity zones. Moving time stays separate because sixty easy minutes and sixty threshold minutes are equal duration but very different cardiovascular loads.\n\nRest days count as zeros. Neither direction is good or bad on its own: a higher week can be a planned build or too much, and the load alone cannot tell those apart. Charge and your own session rating add that context. It stays blank until there are two weeks of history.") - case .bests: - return String(localized: "Measured bests for this sport: the farthest you went, the longest you were out, and your fastest AVERAGE pace within each band of session length.\n\nThe bands matter. A fast 3 km and a fast half marathon are different achievements, so they are kept apart rather than competing for one 'fastest' line.\n\nThese are averages over a whole session, never splits. NOOP stores one distance and one duration per session, so 'your fastest 5 km' inside a longer run is a claim the data cannot support and is deliberately not offered.") + private var explainers: some View { + var items = [ + ExplainerItem(id: "load", symbol: "function", title: String(localized: "Cardio load"), + subtitle: String(localized: "How your cardio load is calculated"), + text: String(localized: "How much cardiovascular work the last 7 days asked of you, against your own level over the last 28 days. It is a percentage, not a score: +18 % means the recent week ran about a fifth above your usual.\n\nThe underlying signal is additive TRIMP, derived from heart rate and time in intensity zones. Moving time stays separate because sixty easy minutes and sixty threshold minutes are equal duration but very different cardiovascular loads.\n\nRest days count as zeros. Neither direction is good or bad on its own: a higher week can be a planned build or too much, and the load alone cannot tell those apart. Charge and your own session rating add that context. It stays blank until there are two weeks of history.")), + ExplainerItem(id: "bests", symbol: "trophy", title: String(localized: "Your bests"), + subtitle: String(localized: "What counts as a best"), + text: String(localized: "Measured bests for this sport: the farthest you went, the longest you were out, and your fastest AVERAGE pace within each band of session length.\n\nThe bands matter. A fast 3 km and a fast half marathon are different achievements, so they are kept apart rather than competing for one 'fastest' line.\n\nThese are averages over a whole session, never splits. NOOP stores one distance and one duration per session, so 'your fastest 5 km' inside a longer run is a claim the data cannot support and is deliberately not offered.")), + ] + if let split = model.zoneSplit, split.total > 0 { + items.append(ExplainerItem(id: "zones", symbol: "waveform.path.ecg", + title: String(localized: "Intensity"), + subtitle: String(localized: "Where the zone split comes from"), + text: zoneProvenanceText(split))) } + return ExplainerRows(items: items) } // MARK: - Coach @@ -904,7 +807,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/TrainingDesignKit.swift b/Strand/Screens/TrainingDesignKit.swift new file mode 100644 index 0000000000..37fe82ced6 --- /dev/null +++ b/Strand/Screens/TrainingDesignKit.swift @@ -0,0 +1,779 @@ +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, maxHeight: .infinity, alignment: .topLeading) + .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))) + } + } + + 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 week's key figures. Two per row on a phone, so a four-digit distance or a double-digit duration +/// keeps its full size; all in one row only where the width allows it. +struct KPIStrip: View { + let lane: TrainingLane + let items: [KPIItem] + + var body: some View { + NoopCard(padding: NoopMetrics.cardPadding) { + ViewThatFits(in: .horizontal) { + grid(columns: min(items.count, 4)) + .frame(minWidth: CGFloat(min(items.count, 4)) * 150) + grid(columns: min(items.count, 2)) + } + } + } + + private func grid(columns: Int) -> some View { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: NoopMetrics.space4, alignment: .topLeading), + count: max(columns, 1)), + alignment: .leading, spacing: NoopMetrics.space4) { + ForEach(items) { item in cell(item) } + } + } + + private func cell(_ item: KPIItem) -> some View { + HStack(alignment: .top, spacing: NoopMetrics.space2) { + ZStack { + Circle().fill(lane.color.opacity(0.2)) + Image(systemName: item.icon) + .font(StrandFont.rounded(13, weight: .semibold)) + .foregroundStyle(lane.color) + } + .frame(width: 30, height: 30) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(item.label) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(1) + .minimumScaleFactor(0.8) + 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(22, weight: .bold)) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.7) + if let caption = item.caption { + Text(caption) + .font(StrandFont.caption) + .foregroundStyle(lane.bright) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + } + } + .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.35 : 0.7))) + .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 { + VStack(alignment: .leading, spacing: NoopMetrics.space2) { + HStack(spacing: NoopMetrics.space4) { + legendDot(lane.color, title) + if span != .week, usualWeek != nil { + legendDot(lane.color.opacity(0.3), String(localized: "Your usual week")) + } + if bars.contains(where: \.containsUnknown) { + legendDot(lane.color.opacity(0.35), String(localized: "Partly unmeasured")) + } + } + if span != .week, usualWeek == nil { + Text("Your usual range appears after eight complete weeks.") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + 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 + var headline: String? = nil + 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(2) + .minimumScaleFactor(0.85) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(StrandFont.caption.weight(.semibold)) + .foregroundStyle(StrandPalette.textTertiary) + } + 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(3) + .fixedSize(horizontal: false, vertical: true) + } + 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/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 39324fe807..114ee50a77 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,148 @@ 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 strengthByDay = TrainingLoadLanes.strengthByDay(strengthWorkouts, tzOffsetSeconds: offset) + let cardioSeries = TrainingLoadLanes.cardioSeries(sessions: unified, resolution: cardioResolution, + tzOffsetSeconds: offset) + let cardioByDay = cardioSeries.byDay + 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 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 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, + strengthDaily: strengthByDay, + cardioDaily: cardioByDay, + cardioUnknownDays: cardioUnknown, + workouts: strengthWorkouts, templates: templates, + days: dailyRows, tzOffsetSeconds: offset) + 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) + let sustained = TrainingStatusModel.sustainedOverreaching(history: history, strengthResponse: response, + cardioDirection: vo2max.direction, + recovery: recovery) + let provisionalStrengthRing: ProvisionalStrengthRingReading? + if strengthLane.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: strengthLane, + cardio: cardioLane, + session: Lane(sevenDayTotal: TrainingLoadLanes.lastSeven(sessionByDay, through: today), + sevenDayWorkingSets: 0, + trend: sessionRelative.trend, + relative: sessionRelative, + isLowerBound: TrainingLoadLanes.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) @@ -440,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 @@ -511,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.", @@ -521,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 { @@ -590,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)) - } - - private var strengthRingReading: LoadRingReading? { - if let status = model.strength?.status { return LoadRingReading(status) } - return model.provisionalStrengthRing.map(LoadRingReading.init) + .fixedSize(horizontal: false, vertical: true) + } + + /// 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() + } + } + } + + 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 } - .accessibilityElement(children: .combine) + } + + 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 @@ -700,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". @@ -730,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 } @@ -802,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) { @@ -836,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 @@ -1395,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)) } } @@ -1464,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. 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/StrandTests/TrainingLoadLanesTests.swift b/StrandTests/TrainingLoadLanesTests.swift new file mode 100644 index 0000000000..7f236bdcf2 --- /dev/null +++ b/StrandTests/TrainingLoadLanesTests.swift @@ -0,0 +1,190 @@ +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") + } + + /// 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 { + 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: -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: 0, through: -(days - 2), by: -2).reversed() { + 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") + } + + 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") + } +} diff --git a/Tools/translations/de.json b/Tools/translations/de.json index 6a4d9ec727..5c69d96326 100644 --- a/Tools/translations/de.json +++ b/Tools/translations/de.json @@ -1414,5 +1414,25 @@ "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", + "%lld %% of cardio load": "%lld %% der Cardio-Last", + "%lld of %lld sessions complete": "%1$lld von %2$lld Einheiten vollständig", + "How your cardio load is calculated": "Wie deine Cardio-Last berechnet wird", + "Most used activity": "Häufigste Aktivität", + "What counts as a best": "Was als Bestwert zählt", + "Where the zone split comes from": "Woher die Zonenverteilung stammt", + "usual %lld bpm": "üblich %lld bpm" } diff --git a/Tools/translations/es.json b/Tools/translations/es.json index e94d318cda..06489a1267 100644 --- a/Tools/translations/es.json +++ b/Tools/translations/es.json @@ -1415,5 +1415,25 @@ "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", + "%lld %% of cardio load": "%lld %% de la carga cardio", + "%lld of %lld sessions complete": "%1$lld de %2$lld sesiones completas", + "How your cardio load is calculated": "Cómo se calcula tu carga cardio", + "Most used activity": "Actividad más frecuente", + "What counts as a best": "Qué cuenta como marca personal", + "Where the zone split comes from": "De dónde sale el reparto por zonas", + "usual %lld bpm": "habitual %lld ppm" } diff --git a/Tools/translations/fr.json b/Tools/translations/fr.json index 58be4340b6..920a885871 100644 --- a/Tools/translations/fr.json +++ b/Tools/translations/fr.json @@ -1416,5 +1416,25 @@ "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", + "%lld %% of cardio load": "%lld %% de la charge cardio", + "%lld of %lld sessions complete": "%1$lld séances complètes sur %2$lld", + "How your cardio load is calculated": "Comment votre charge cardio est calculée", + "Most used activity": "Activité la plus pratiquée", + "What counts as a best": "Ce qui compte comme record", + "Where the zone split comes from": "D’où vient la répartition par zones", + "usual %lld bpm": "habituel %lld bpm" } diff --git a/Tools/translations/it.json b/Tools/translations/it.json index f5913484ff..9d3b50646f 100644 --- a/Tools/translations/it.json +++ b/Tools/translations/it.json @@ -1509,5 +1509,25 @@ "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", + "%lld %% of cardio load": "%lld %% del carico cardio", + "%lld of %lld sessions complete": "%1$lld sessioni complete su %2$lld", + "How your cardio load is calculated": "Come viene calcolato il tuo carico cardio", + "Most used activity": "Attività più frequente", + "What counts as a best": "Cosa conta come record", + "Where the zone split comes from": "Da dove viene la ripartizione per zone", + "usual %lld bpm": "abituale %lld bpm" } diff --git a/Tools/translations/pl.json b/Tools/translations/pl.json index ef83ca4116..00745346f1 100644 --- a/Tools/translations/pl.json +++ b/Tools/translations/pl.json @@ -2551,5 +2551,25 @@ "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", + "%lld %% of cardio load": "%lld %% obciążenia cardio", + "%lld of %lld sessions complete": "%1$lld z %2$lld sesji kompletnych", + "How your cardio load is calculated": "Jak liczone jest Twoje obciążenie cardio", + "Most used activity": "Najczęstsza aktywność", + "What counts as a best": "Co liczy się jako rekord", + "Where the zone split comes from": "Skąd pochodzi podział na strefy", + "usual %lld bpm": "zwykle %lld bpm" } diff --git a/Tools/translations/pt-PT.json b/Tools/translations/pt-PT.json index ba3ed531e8..c7f770f632 100644 --- a/Tools/translations/pt-PT.json +++ b/Tools/translations/pt-PT.json @@ -1415,5 +1415,25 @@ "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", + "%lld %% of cardio load": "%lld %% da carga cardio", + "%lld of %lld sessions complete": "%1$lld de %2$lld sessões completas", + "How your cardio load is calculated": "Como é calculada a tua carga cardio", + "Most used activity": "Atividade mais frequente", + "What counts as a best": "O que conta como recorde", + "Where the zone split comes from": "De onde vem a divisão por zonas", + "usual %lld bpm": "habitual %lld bpm" } diff --git a/Tools/translations/ru.json b/Tools/translations/ru.json index 9992fb4b02..3d1176ee3a 100644 --- a/Tools/translations/ru.json +++ b/Tools/translations/ru.json @@ -1392,5 +1392,25 @@ "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": "к вашему обычному", + "%lld %% of cardio load": "%lld %% кардионагрузки", + "%lld of %lld sessions complete": "%1$lld из %2$lld тренировок полные", + "How your cardio load is calculated": "Как рассчитывается кардионагрузка", + "Most used activity": "Основная активность", + "What counts as a best": "Что считается рекордом", + "Where the zone split comes from": "Откуда берётся распределение по зонам", + "usual %lld bpm": "обычно %lld уд/мин" } diff --git a/Tools/translations/zh-Hans.json b/Tools/translations/zh-Hans.json index c3195e54eb..e079926a12 100644 --- a/Tools/translations/zh-Hans.json +++ b/Tools/translations/zh-Hans.json @@ -1516,5 +1516,25 @@ "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": "对比平常", + "%lld %% of cardio load": "占有氧负荷 %lld%%", + "%lld of %lld sessions complete": "%2$lld 次训练中 %1$lld 次完整", + "How your cardio load is calculated": "有氧负荷的计算方式", + "Most used activity": "最常进行的运动", + "What counts as a best": "什么算作最佳成绩", + "Where the zone split comes from": "心率区间分布的来源", + "usual %lld bpm": "平常 %lld 次/分" } diff --git a/Tools/translations/zh-Hant.json b/Tools/translations/zh-Hant.json index 13a74fb020..13b6e31003 100644 --- a/Tools/translations/zh-Hant.json +++ b/Tools/translations/zh-Hant.json @@ -1570,5 +1570,25 @@ "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": "對比平常", + "%lld %% of cardio load": "佔有氧負荷 %lld%%", + "%lld of %lld sessions complete": "%2$lld 次訓練中 %1$lld 次完整", + "How your cardio load is calculated": "有氧負荷的計算方式", + "Most used activity": "最常進行的運動", + "What counts as a best": "什麼算作最佳成績", + "Where the zone split comes from": "心率區間分佈的來源", + "usual %lld bpm": "平常 %lld 次/分" }