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..5dd0e62d97 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -435461,6 +435461,838 @@ } } } + }, + "4W" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "4W" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "4W" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "4S" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "4S" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "4S" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "4T" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "4S" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "4Н" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "4周" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "4週" + } + } + } + }, + "12W" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "12W" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "12W" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "12S" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "12S" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "12S" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "12T" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "12S" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "12Н" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "12周" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "12週" + } + } + } + }, + "About usual" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Etwa wie üblich" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "About usual" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Como de costumbre" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Comme d’habitude" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Come al solito" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jak zwykle" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Como habitualmente" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Как обычно" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "与平常相当" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "與平常相當" + } + } + } + }, + "Above usual" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Über dem Üblichen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Above usual" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Por encima de lo habitual" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Au-dessus de l’habitude" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sopra il solito" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Powyżej zwykłego" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acima do habitual" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выше обычного" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "高于平常" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "高於平常" + } + } + } + }, + "No comparison yet" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noch kein Vergleich" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No comparison yet" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aún sin comparación" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pas encore de comparaison" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ancora nessun confronto" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Brak porównania" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ainda sem comparação" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сравнения пока нет" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂无对比" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "暫無對比" + } + } + } + }, + "Opens the details" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öffnet die Details" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opens the details" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abre los detalles" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvre les détails" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apre i dettagli" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otwiera szczegóły" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abre os detalhes" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Открывает подробности" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开详情" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "打開詳情" + } + } + } + }, + "Partly unmeasured" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Teilweise ungemessen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Partly unmeasured" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medido en parte" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Partiellement non mesuré" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Misurato solo in parte" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Częściowo niezmierzone" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Parcialmente por medir" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Частично не измерено" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "部分未测量" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "部分未測量" + } + } + } + }, + "Provisional" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vorläufig" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Provisional" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Provisional" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Provisoire" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Provvisorio" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wstępnie" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Provisório" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Предварительно" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "初步" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "初步" + } + } + } + }, + "Usual high" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Üblich oben" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usual high" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Límite superior habitual" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Haut habituel" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limite alto abituale" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zwykła górna granica" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limite superior habitual" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обычный максимум" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "平常上限" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "平常上限" + } + } + } + }, + "Usual low" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Üblich unten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usual low" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Límite inferior habitual" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bas habituel" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limite basso abituale" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zwykła dolna granica" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Limite inferior habitual" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обычный минимум" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "平常下限" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "平常下限" + } + } + } + }, + "Your usual range appears after eight complete weeks." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dein üblicher Bereich erscheint nach acht vollständigen Wochen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your usual range appears after eight complete weeks." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu rango habitual aparece tras ocho semanas completas." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre plage habituelle apparaît après huit semaines complètes." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Il tuo intervallo abituale compare dopo otto settimane complete." + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twój zwykły zakres pojawi się po ośmiu pełnych tygodniach." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "O teu intervalo habitual aparece após oito semanas completas." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваш обычный диапазон появится после восьми полных недель." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "满八个完整周后显示你的平常范围。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "滿八個完整週後顯示你的平常範圍。" + } + } + } + }, + "Your usual week" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deine übliche Woche" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your usual week" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu semana habitual" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre semaine habituelle" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "La tua settimana abituale" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Twój zwykły tydzień" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "A tua semana habitual" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ваша обычная неделя" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你的平常一周" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "你的平常一週" + } + } + } + }, + "vs. your usual" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "ggü. deinem Üblichen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "vs. your usual" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "frente a lo habitual" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "vs votre habitude" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "rispetto al solito" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wobec zwykłego" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "face ao habitual" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "к вашему обычному" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "对比平常" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "對比平常" + } + } + } } }, "version" : "1.0" diff --git a/Strand/Screens/CardioModel.swift b/Strand/Screens/CardioModel.swift index 0697d9ce10..729d3b1b61 100644 --- a/Strand/Screens/CardioModel.swift +++ b/Strand/Screens/CardioModel.swift @@ -58,7 +58,9 @@ final class CardioModel: ObservableObject { distanceM: 0, energyKcal: 0, effort: nil, sessionsWithDistance: 0, bySport: []) @Published private(set) var typicalMinutes: ClosedRange? - @Published private(set) var load: LoadTrend? + /// The selected week's cardio lane, read exactly as Training Load reads it. + @Published private(set) var lane: TrainingLoadModel.Lane? + @Published private(set) var laneRatios: [TrainingLoadModel.RatioPoint] = [] @Published private(set) var weekCharge: Double? /// The displayed week's time in each heart-rate zone. Nil when no zone set is known yet, or when /// nothing that week carried a trace complete enough to bin. @@ -76,6 +78,11 @@ final class CardioModel: ObservableObject { private var fusedVisible: [UnifiedTrainingSession] = [] /// Sessions another record already described, so the zone split counts those minutes once. private var duplicateSessionIds: Set = [] + /// The lane reads every training session, strength included, over the history window plus the + /// lookback a reading needs, so the oldest selectable week still compares like Training Load does. + private var laneSessions: [UnifiedTrainingSession] = [] + private var laneResolution = TrainingCardioLoadResolution() + private var laneSeries: TrainingLoadLanes.CardioSeries? // The selected sport @Published private(set) var sportHistory: [CardioSessionMetrics] = [] @@ -100,7 +107,8 @@ final class CardioModel: ObservableObject { private struct WeekBundle: Sendable { let week: CardioWeekSummary let typical: ClosedRange? - let load: LoadTrend? + let lane: TrainingLoadModel.Lane? + let laneRatios: [TrainingLoadModel.RatioPoint] let zones: CardioZoneSplit? } @@ -115,6 +123,8 @@ final class CardioModel: ObservableObject { // differently apart instead of hiding them. let visible = fusion.sessions.filter { $0.kind != .strength } let cardio = await repo.cardioLoads(for: visible) + let laneFusion = await repo.trainingSessions(days: range.days + TrainingLoadLanes.lookbackDays) + let laneResolution = await repo.cardioLoads(for: laneFusion.sessions) fusedVisible = visible duplicateSessionIds = cardio.duplicateSessionIds let rows = visible.map(\.row) @@ -126,6 +136,14 @@ final class CardioModel: ObservableObject { let enduranceStarts = Set(visible.filter { $0.kind == .endurance || $0.kind == .multisport } .map { $0.row.startTs }) + let laneSeries = await Task.detached(priority: .userInitiated) { + TrainingLoadLanes.cardioSeries(sessions: laneFusion.sessions, resolution: laneResolution, + tzOffsetSeconds: offset) + }.value + self.laneSessions = laneFusion.sessions + self.laneResolution = laneResolution + self.laneSeries = laneSeries + let prepared = await Task.detached(priority: .userInitiated) { () -> ([CardioSessionMetrics], [SportChoice]) in let sessions = CardioSession.sessions(rows, tzOffsetSeconds: offset, cardioLoadByStart: loadByStart) @@ -180,18 +198,26 @@ final class CardioModel: ObservableObject { } let anchor = weekAnchorDay - let endDate = weekEndDate let all = sessions let offset = tzOffset + let laneDay = TrainingLoadLanes.readingDay(monday: monday, today: Repository.localDayKey(Date())) + let laneSessions = self.laneSessions + let laneResolution = self.laneResolution + let laneSeries = self.laneSeries // Outside the detached task: binning zones is an async read on the repository, and its result // travels into the bundle as a finished value so the week's cache holds it too. let zones = await weekZoneSplit(repo: repo, monday: monday, sunday: sunday) let bundle = await Task.detached(priority: .userInitiated) { () -> WeekBundle in + let lane = laneSeries.map { + TrainingLoadLanes.cardioLane(sessions: laneSessions, resolution: laneResolution, series: $0, + through: laneDay, tzOffsetSeconds: offset) + } return WeekBundle(week: CardioSession.week(containing: anchor, sessions: all), typical: CardioSession.typicalWeeklyMinutes(all, endingBefore: anchor), - load: CardioSession.cardioLoadTrend(all, asOf: endDate, - tzOffsetSeconds: offset), + lane: lane, + laneRatios: TrainingLoadLanes.ratios(strengthByDay: nil, cardio: laneSeries, + through: laneDay), zones: zones) }.value @@ -219,7 +245,8 @@ final class CardioModel: ObservableObject { private func apply(_ bundle: WeekBundle) { week = bundle.week typicalMinutes = bundle.typical - load = bundle.load + lane = bundle.lane + laneRatios = bundle.laneRatios zoneSplit = bundle.zones } @@ -282,14 +309,6 @@ final class CardioModel: ObservableObject { WeeklyDigestEngine.addDays(Repository.localDayKey(Date()), weekOffset * 7) } - var weekEndDate: Date { - guard let monday = WeeklyDigestEngine.mondayOfWeek(containing: weekAnchorDay), - let sunday = WeightSeries.date(forDay: WeeklyDigestEngine.addDays(monday, 6)) else { - return Date() - } - return min(sunday, Date()) - } - var minWeekOffset: Int { guard let earliest = sessions.map(\.day).min(), let earliestMon = WeeklyDigestEngine.mondayOfWeek(containing: earliest), diff --git a/Strand/Screens/CardioView.swift b/Strand/Screens/CardioView.swift index e57896c355..5408b84d04 100644 --- a/Strand/Screens/CardioView.swift +++ b/Strand/Screens/CardioView.swift @@ -189,7 +189,7 @@ struct CardioView: View { @ViewBuilder private var loadTile: some View { - let load = model.load + let load = model.lane?.trend tile(icon: "chart.bar.fill", label: String(localized: "Load trend"), value: load.map { signedPercent($0.percentChange) } ?? "—", @@ -904,7 +904,7 @@ struct CardioView: View { if model.week.distanceM > 0 { parts.append(String(format: "%.1f km", model.week.distanceM / 1000)) } - if let load = model.load { + if let load = model.lane?.trend { parts.append(String(format: "cardio load %+.0f%% vs own 28-day level", load.percentChange)) } if let sport = model.selectedSport, let line = model.paceTrend { diff --git a/Strand/Screens/StrengthModel.swift b/Strand/Screens/StrengthModel.swift index 44c696fd9f..84d504e052 100644 --- a/Strand/Screens/StrengthModel.swift +++ b/Strand/Screens/StrengthModel.swift @@ -101,7 +101,9 @@ final class StrengthModel: ObservableObject { mondayKey: "", sessionCount: 0, workingSetCount: 0, volumeLoadKg: 0, setsByMuscle: [:], secondarySetsByMuscle: [:], unattributedSetCount: 0) @Published private(set) var typicalBands: [HevyMuscleGroup: ClosedRange] = [:] - @Published private(set) var strengthLoad: LoadTrend? + /// The selected week's strength lane, read exactly as Training Load reads it. + @Published private(set) var lane: TrainingLoadModel.Lane? + @Published private(set) var laneRatios: [TrainingLoadModel.RatioPoint] = [] @Published private(set) var weekStimulus: [HevyMuscleGroup: Double] = [:] @Published private(set) var typicalWeek: [HevyMuscleGroup: Double] = [:] @Published private(set) var weekCharge: Double? @@ -138,6 +140,9 @@ final class StrengthModel: ObservableObject { private var index = MuscleStimulus.SessionStimulusIndex(workouts: [], templates: [:]) /// The wearer's weigh-ins, for pricing bodyweight work at the body that performed it. private var bodyweight = BodyweightTimeline(points: []) + /// Workouts over the history window plus the lane lookback, and their weighted sets per day. + private var laneWorkouts: [HevyWorkout] = [] + private var laneByDay: [String: Double] = [:] /// Week-scoped results, keyed by the week's Monday. The bands and the usual week depend on nothing /// the stepper changes except this key, so stepping back and forward again is free. private var weekCache: [String: WeekBundle] = [:] @@ -147,7 +152,8 @@ final class StrengthModel: ObservableObject { private struct WeekBundle: Sendable { let week: StrengthSession.WeekSummary let bands: [HevyMuscleGroup: ClosedRange] - let load: LoadTrend? + let lane: TrainingLoadModel.Lane + let laneRatios: [TrainingLoadModel.RatioPoint] let stimulus: [HevyMuscleGroup: Double] let typical: [HevyMuscleGroup: Double] let balance: [StrengthBalance.Reading] @@ -168,10 +174,12 @@ final class StrengthModel: ObservableObject { async let historyRead = repo.resolvedStrengthHistory(days: historyDays) async let fusedRead = repo.trainingSessions(days: historyDays) + async let laneHistoryRead = repo.resolvedStrengthHistory(days: historyDays + TrainingLoadLanes.lookbackDays) let history = await historyRead let sessions = history.workouts let catalogue = history.templates let fused = await fusedRead + let laneWorkouts = await laneHistoryRead.workouts let observations = ((try? await store.muscleRecoveryFeedback()) ?? []).compactMap { row in MuscleRecovery.Feeling(rawValue: row.feeling).map { MuscleRecovery.Observation(group: row.muscleGroup, ts: row.ts, feeling: $0) @@ -210,10 +218,13 @@ final class StrengthModel: ObservableObject { ratedShare: index.total().ratedShare, choices: StrengthSession.exerciseFrequency(sessions) .map { ExerciseChoice(templateId: $0.templateId, sessions: $0.sessions) }, - unmapped: history.unmappedExerciseTitles) + unmapped: history.unmappedExerciseTitles, + laneByDay: TrainingLoadLanes.strengthByDay(laneWorkouts, tzOffsetSeconds: offset)) }.value index = prepared.index + self.laneWorkouts = laneWorkouts + laneByDay = prepared.laneByDay bodyweight = BodyweightTimeline(points: weighIns) weekCache.removeAll() @@ -307,6 +318,7 @@ final class StrengthModel: ObservableObject { let ratedShare: Double let choices: [ExerciseChoice] let unmapped: [String] + let laneByDay: [String: Double] } // MARK: - The week @@ -333,7 +345,9 @@ final class StrengthModel: ObservableObject { } let anchor = weekAnchorDay - let endDate = weekEndDate + let laneDay = TrainingLoadLanes.readingDay(monday: monday, today: Repository.localDayKey(Date())) + let laneWorkouts = self.laneWorkouts + let laneByDay = self.laneByDay let sessions = workouts let catalogue = templates let offset = tzOffset @@ -364,7 +378,9 @@ final class StrengthModel: ObservableObject { week: week, bands: StrengthSession.typicalWeeklySets(sessions, templates: catalogue, endingBefore: anchor, tzOffsetSeconds: offset), - load: StrengthSession.strengthLoadTrend(sessions, asOf: endDate, tzOffsetSeconds: offset), + lane: TrainingLoadLanes.strengthLane(workouts: laneWorkouts, byDay: laneByDay, through: laneDay, + tzOffsetSeconds: offset), + laneRatios: TrainingLoadLanes.ratios(strengthByDay: laneByDay, cardio: nil, through: laneDay), stimulus: index.week(containing: anchor).byMuscle, typical: MuscleStimulus.typicalWeeklyStimulus(index: index, endingBefore: anchor), balance: StrengthBalance.readings(setsByMuscle: week.setsByMuscle), @@ -383,7 +399,8 @@ final class StrengthModel: ObservableObject { private func apply(_ bundle: WeekBundle) { week = bundle.week typicalBands = bundle.bands - strengthLoad = bundle.load + lane = bundle.lane + laneRatios = bundle.laneRatios weekStimulus = bundle.stimulus typicalWeek = bundle.typical balance = bundle.balance diff --git a/Strand/Screens/StrengthView.swift b/Strand/Screens/StrengthView.swift index 3dc024e5dd..795ca490d2 100644 --- a/Strand/Screens/StrengthView.swift +++ b/Strand/Screens/StrengthView.swift @@ -552,7 +552,7 @@ struct StrengthView: View { /// team-sport distance research that never covered set counts. The ratio is still there on /// `LoadTrend` for anything that needs it. private var strengthLoadTile: some View { - let load = model.strengthLoad + let load = model.lane?.trend return tile(icon: "chart.bar.fill", label: String(localized: "Strength load"), value: load.map { signedPercent($0.percentChange) } ?? "—", @@ -1821,7 +1821,7 @@ struct StrengthView: View { ?? "\(row.group.label) \(row.sets)" } .joined(separator: ", ") if !muscles.isEmpty { parts.append("working sets — " + muscles) } - if let load = model.strengthLoad { + if let load = model.lane?.trend { // The coach gets the same framing the tile shows: effort-weighted sets against this // person's own recent level, as a percentage. Handing it a bare ratio invited it to // quote 0.8–1.3 bands that were never validated on set counts. diff --git a/Strand/Screens/TrainingDesignKit.swift b/Strand/Screens/TrainingDesignKit.swift new file mode 100644 index 0000000000..981702e7f1 --- /dev/null +++ b/Strand/Screens/TrainingDesignKit.swift @@ -0,0 +1,769 @@ +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))) + } else { + Text(verbatim: "—") + .font(StrandFont.number(compact ? 30 : 42, weight: .bold)) + .foregroundStyle(StrandPalette.textTertiary) + } + } + + private var sparkline: some View { + Sparkline(values: trend, gradient: Gradient(colors: [lane.deep, lane.bright]), + lineWidth: 2.5, showsArea: true, showsHead: true, showsHover: false) + .frame(height: compact ? 34 : 48) + .frame(maxWidth: .infinity) + .accessibilityHidden(true) + } +} + +// MARK: - KPI strip + +struct KPIItem: Identifiable { + let id: String + let icon: String + let value: String + let label: String + var caption: String? = nil + var info: (() -> Void)? = nil +} + +/// A row of a week's key figures. Up to four sit in one row; more wrap into rows of three. +struct KPIStrip: View { + let lane: TrainingLane + let items: [KPIItem] + + var body: some View { + let columns = items.count <= 4 ? max(items.count, 1) : 3 + NoopCard(padding: NoopMetrics.space3) { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: NoopMetrics.space2), count: columns), + alignment: .leading, spacing: NoopMetrics.space3) { + ForEach(items) { item in cell(item) } + } + } + } + + private func cell(_ item: KPIItem) -> some View { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 0) { + ZStack { + Circle().fill(lane.color.opacity(0.2)) + Image(systemName: item.icon) + .font(StrandFont.rounded(11, weight: .semibold)) + .foregroundStyle(lane.color) + } + .frame(width: 24, height: 24) + .accessibilityHidden(true) + Spacer(minLength: 0) + if let info = item.info { + Button(action: info) { + Image(systemName: "info.circle") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("What this means")) + } + } + Text(item.value) + .font(StrandFont.number(20, weight: .bold)) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.55) + Text(item.label) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(item.caption ?? " ") + .font(StrandFont.caption) + .foregroundStyle(lane.bright) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(verbatim: "\(item.label): \(item.value)\(item.caption.map { ", " + $0 } ?? "")")) + } +} + +// MARK: - Load over time + +enum LoadHistorySpan: String, CaseIterable, Identifiable, Sendable { + case week, fourWeeks, twelveWeeks + var id: String { rawValue } + var label: String { + switch self { + case .week: return String(localized: "7D") + case .fourWeeks: return String(localized: "4W") + case .twelveWeeks: return String(localized: "12W") + } + } +} + +/// The bars a load chart draws, worked out without SwiftUI so the windows can be tested. +enum LoadHistoryBuckets { + struct Bar: Identifiable, Equatable, Sendable { + /// First day the bar covers. + let start: String + /// Known load in the bar; nil for a day that has not happened yet. + let value: Double? + /// Part of the bar is training the data could not price, so the value is a lower bound. + let containsUnknown: Bool + /// The day or week the screen is reading. + let isSelected: Bool + var id: String { start } + } + + /// `.week`: the seven days of the week containing `readingDay`. The other spans: whole Monday weeks, + /// ending with that week, summed through `readingDay` for the week still running. + static func bars(byDay: [String: Double], unknownDays: Set, span: LoadHistorySpan, + readingDay: String) -> [Bar] { + guard let monday = WeeklyDigestEngine.mondayOfWeek(containing: readingDay) else { return [] } + switch span { + case .week: + return (0..<7).map { offset in + let day = WeeklyDigestEngine.addDays(monday, offset) + return Bar(start: day, value: day > readingDay ? nil : (byDay[day] ?? 0), + containsUnknown: unknownDays.contains(day), isSelected: day == readingDay) + } + case .fourWeeks, .twelveWeeks: + let weeks = span == .fourWeeks ? 4 : 12 + return (0.. = [] + let readingDay: String + var usualWeek: ClosedRange? = nil + + @State private var span: LoadHistorySpan = .fourWeeks + + var body: some View { + let bars = LoadHistoryBuckets.bars(byDay: byDay, unknownDays: unknownDays, span: span, + readingDay: readingDay) + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.space3) { + HStack(alignment: .center) { + Text(title) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Spacer(minLength: NoopMetrics.space2) + SegmentedPillControl(LoadHistorySpan.allCases, selection: $span) { $0.label } + } + chart(bars) + .frame(height: 170) + legend(bars) + } + } + } + + @ViewBuilder private func chart(_ bars: [LoadHistoryBuckets.Bar]) -> some View { + let band = span == .week ? nil : usualWeek + Chart { + if let band { + RectangleMark(yStart: .value("Usual low", band.lowerBound), + yEnd: .value("Usual high", band.upperBound)) + .foregroundStyle(lane.color.opacity(0.14)) + } + ForEach(bars) { bar in + if let value = bar.value { + BarMark(x: .value("Period", bar.start), y: .value(unit, value), width: .ratio(0.62)) + .foregroundStyle(bar.isSelected + ? AnyShapeStyle(LinearGradient(colors: [lane.bright, lane.deep], + startPoint: .top, endPoint: .bottom)) + : AnyShapeStyle(lane.color.opacity(bar.containsUnknown ? 0.3 : 0.55))) + .cornerRadius(5) + } + } + } + .chartXScale(domain: bars.map(\.start)) + .chartYAxis { + AxisMarks(position: .leading) { _ in + AxisGridLine().foregroundStyle(StrandPalette.hairline) + AxisValueLabel().font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + } + } + .chartXAxis { + AxisMarks { value in + AxisValueLabel { + if let start = value.as(String.self) { + Text(verbatim: axisLabel(start)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + } + } + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(verbatim: accessibilitySummary(bars))) + } + + @ViewBuilder private func legend(_ bars: [LoadHistoryBuckets.Bar]) -> some View { + HStack(spacing: NoopMetrics.space4) { + legendDot(lane.color, title) + if span != .week { + if usualWeek != nil { + legendDot(lane.color.opacity(0.3), String(localized: "Your usual week")) + } else { + Text("Your usual range appears after eight complete weeks.") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + if bars.contains(where: \.containsUnknown) { + legendDot(lane.color.opacity(0.3), String(localized: "Partly unmeasured")) + } + } + } + + private func legendDot(_ color: Color, _ text: String) -> some View { + HStack(spacing: 5) { + Circle().fill(color).frame(width: 8, height: 8) + Text(text).font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary).lineLimit(1) + } + } + + private func axisLabel(_ start: String) -> String { + guard let date = WeightSeries.date(forDay: start) else { return start } + if span == .week { return date.formatted(.dateTime.weekday(.abbreviated)) } + return date.formatted(.dateTime.day().month(.defaultDigits)) + } + + private func accessibilitySummary(_ bars: [LoadHistoryBuckets.Bar]) -> String { + bars.compactMap { bar in + bar.value.map { "\(axisLabel(bar.start)): \(Int($0.rounded())) \(unit)" } + }.joined(separator: ", ") + } +} + +// MARK: - Summary tile + +/// A compact fact that opens its full card. The mini content shows enough to decide whether to open it. +struct SummaryTile: View { + let symbol: String + let tint: Color + let title: String + 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(1) + .minimumScaleFactor(0.7) + 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..33dc49555b 100644 --- a/Tools/translations/de.json +++ b/Tools/translations/de.json @@ -1414,5 +1414,18 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP verwendet die folgenden Inhalte Dritter unter deren eigener Lizenz. Eine Lizenz in einem Bereich (Code, Daten, Medien) gilt nicht automatisch auch für einen anderen.", "Third-party content NOOP uses under its own licence.": "Inhalte Dritter, die NOOP unter deren eigener Lizenz verwendet.", "Workout title": "Workout-Titel", - "Workout title (optional)": "Workout-Titel (optional)" + "Workout title (optional)": "Workout-Titel (optional)", + "4W": "4W", + "12W": "12W", + "About usual": "Etwa wie üblich", + "Above usual": "Über dem Üblichen", + "No comparison yet": "Noch kein Vergleich", + "Opens the details": "Öffnet die Details", + "Partly unmeasured": "Teilweise ungemessen", + "Provisional": "Vorläufig", + "Usual high": "Üblich oben", + "Usual low": "Üblich unten", + "Your usual range appears after eight complete weeks.": "Dein üblicher Bereich erscheint nach acht vollständigen Wochen.", + "Your usual week": "Deine übliche Woche", + "vs. your usual": "ggü. deinem Üblichen" } diff --git a/Tools/translations/es.json b/Tools/translations/es.json index e94d318cda..108abc9e30 100644 --- a/Tools/translations/es.json +++ b/Tools/translations/es.json @@ -1415,5 +1415,18 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP utiliza el siguiente contenido de terceros bajo su propia licencia. Una licencia en un ámbito (código, datos, medios) no se considera una licencia en otro.", "Third-party content NOOP uses under its own licence.": "Contenido de terceros que NOOP utiliza bajo su propia licencia.", "Workout title": "Título del entrenamiento", - "Workout title (optional)": "Título del entrenamiento (opcional)" + "Workout title (optional)": "Título del entrenamiento (opcional)", + "4W": "4S", + "12W": "12S", + "About usual": "Como de costumbre", + "Above usual": "Por encima de lo habitual", + "No comparison yet": "Aún sin comparación", + "Opens the details": "Abre los detalles", + "Partly unmeasured": "Medido en parte", + "Provisional": "Provisional", + "Usual high": "Límite superior habitual", + "Usual low": "Límite inferior habitual", + "Your usual range appears after eight complete weeks.": "Tu rango habitual aparece tras ocho semanas completas.", + "Your usual week": "Tu semana habitual", + "vs. your usual": "frente a lo habitual" } diff --git a/Tools/translations/fr.json b/Tools/translations/fr.json index 58be4340b6..2a90c8f485 100644 --- a/Tools/translations/fr.json +++ b/Tools/translations/fr.json @@ -1416,5 +1416,18 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP utilise le contenu tiers suivant sous sa propre licence. Une licence dans un domaine (code, données, médias) n’est pas considérée comme une licence dans un autre.", "Third-party content NOOP uses under its own licence.": "Contenu tiers que NOOP utilise sous sa propre licence.", "Workout title": "Titre de l’entraînement", - "Workout title (optional)": "Titre de l’entraînement (facultatif)" + "Workout title (optional)": "Titre de l’entraînement (facultatif)", + "4W": "4S", + "12W": "12S", + "About usual": "Comme d’habitude", + "Above usual": "Au-dessus de l’habitude", + "No comparison yet": "Pas encore de comparaison", + "Opens the details": "Ouvre les détails", + "Partly unmeasured": "Partiellement non mesuré", + "Provisional": "Provisoire", + "Usual high": "Haut habituel", + "Usual low": "Bas habituel", + "Your usual range appears after eight complete weeks.": "Votre plage habituelle apparaît après huit semaines complètes.", + "Your usual week": "Votre semaine habituelle", + "vs. your usual": "vs votre habitude" } diff --git a/Tools/translations/it.json b/Tools/translations/it.json index f5913484ff..c9f65bdd6c 100644 --- a/Tools/translations/it.json +++ b/Tools/translations/it.json @@ -1509,5 +1509,18 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP utilizza i seguenti contenuti di terze parti secondo la loro licenza. Una licenza in un ambito (codice, dati, media) non vale automaticamente anche per un altro.", "Third-party content NOOP uses under its own licence.": "Contenuti di terze parti che NOOP utilizza secondo la loro licenza.", "Workout title": "Titolo dell’allenamento", - "Workout title (optional)": "Titolo dell’allenamento (facoltativo)" + "Workout title (optional)": "Titolo dell’allenamento (facoltativo)", + "4W": "4S", + "12W": "12S", + "About usual": "Come al solito", + "Above usual": "Sopra il solito", + "No comparison yet": "Ancora nessun confronto", + "Opens the details": "Apre i dettagli", + "Partly unmeasured": "Misurato solo in parte", + "Provisional": "Provvisorio", + "Usual high": "Limite alto abituale", + "Usual low": "Limite basso abituale", + "Your usual range appears after eight complete weeks.": "Il tuo intervallo abituale compare dopo otto settimane complete.", + "Your usual week": "La tua settimana abituale", + "vs. your usual": "rispetto al solito" } diff --git a/Tools/translations/pl.json b/Tools/translations/pl.json index ef83ca4116..c971d11c3c 100644 --- a/Tools/translations/pl.json +++ b/Tools/translations/pl.json @@ -2551,5 +2551,18 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP korzysta z poniższych treści innych firm na podstawie ich własnej licencji. Licencja w jednym obszarze (kod, dane, multimedia) nie jest traktowana jako licencja w innym.", "Third-party content NOOP uses under its own licence.": "Treści innych firm, z których NOOP korzysta na podstawie ich własnej licencji.", "Workout title": "Tytuł treningu", - "Workout title (optional)": "Tytuł treningu (opcjonalnie)" + "Workout title (optional)": "Tytuł treningu (opcjonalnie)", + "4W": "4T", + "12W": "12T", + "About usual": "Jak zwykle", + "Above usual": "Powyżej zwykłego", + "No comparison yet": "Brak porównania", + "Opens the details": "Otwiera szczegóły", + "Partly unmeasured": "Częściowo niezmierzone", + "Provisional": "Wstępnie", + "Usual high": "Zwykła górna granica", + "Usual low": "Zwykła dolna granica", + "Your usual range appears after eight complete weeks.": "Twój zwykły zakres pojawi się po ośmiu pełnych tygodniach.", + "Your usual week": "Twój zwykły tydzień", + "vs. your usual": "wobec zwykłego" } diff --git a/Tools/translations/pt-PT.json b/Tools/translations/pt-PT.json index ba3ed531e8..1f9800a763 100644 --- a/Tools/translations/pt-PT.json +++ b/Tools/translations/pt-PT.json @@ -1415,5 +1415,18 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "A NOOP utiliza o seguinte conteúdo de terceiros ao abrigo da sua própria licença. Uma licença num domínio (código, dados, multimédia) não é considerada uma licença noutro.", "Third-party content NOOP uses under its own licence.": "Conteúdo de terceiros que a NOOP utiliza ao abrigo da sua própria licença.", "Workout title": "Título do treino", - "Workout title (optional)": "Título do treino (opcional)" + "Workout title (optional)": "Título do treino (opcional)", + "4W": "4S", + "12W": "12S", + "About usual": "Como habitualmente", + "Above usual": "Acima do habitual", + "No comparison yet": "Ainda sem comparação", + "Opens the details": "Abre os detalhes", + "Partly unmeasured": "Parcialmente por medir", + "Provisional": "Provisório", + "Usual high": "Limite superior habitual", + "Usual low": "Limite inferior habitual", + "Your usual range appears after eight complete weeks.": "O teu intervalo habitual aparece após oito semanas completas.", + "Your usual week": "A tua semana habitual", + "vs. your usual": "face ao habitual" } diff --git a/Tools/translations/ru.json b/Tools/translations/ru.json index 9992fb4b02..9c6c4326e2 100644 --- a/Tools/translations/ru.json +++ b/Tools/translations/ru.json @@ -1392,5 +1392,18 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP использует следующий сторонний контент на условиях его собственной лицензии. Лицензия в одной области (код, данные, медиа) не считается лицензией в другой.", "Third-party content NOOP uses under its own licence.": "Сторонний контент, который NOOP использует на условиях его собственной лицензии.", "Workout title": "Название тренировки", - "Workout title (optional)": "Название тренировки (необязательно)" + "Workout title (optional)": "Название тренировки (необязательно)", + "4W": "4Н", + "12W": "12Н", + "About usual": "Как обычно", + "Above usual": "Выше обычного", + "No comparison yet": "Сравнения пока нет", + "Opens the details": "Открывает подробности", + "Partly unmeasured": "Частично не измерено", + "Provisional": "Предварительно", + "Usual high": "Обычный максимум", + "Usual low": "Обычный минимум", + "Your usual range appears after eight complete weeks.": "Ваш обычный диапазон появится после восьми полных недель.", + "Your usual week": "Ваша обычная неделя", + "vs. your usual": "к вашему обычному" } diff --git a/Tools/translations/zh-Hans.json b/Tools/translations/zh-Hans.json index c3195e54eb..bce1b8cbf8 100644 --- a/Tools/translations/zh-Hans.json +++ b/Tools/translations/zh-Hans.json @@ -1516,5 +1516,18 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP 在其自身许可下使用以下第三方内容。一个领域(代码、数据、媒体)的许可并不等同于另一个领域的许可。", "Third-party content NOOP uses under its own licence.": "NOOP 在其自身许可下使用的第三方内容。", "Workout title": "训练标题", - "Workout title (optional)": "训练标题(可选)" + "Workout title (optional)": "训练标题(可选)", + "4W": "4周", + "12W": "12周", + "About usual": "与平常相当", + "Above usual": "高于平常", + "No comparison yet": "暂无对比", + "Opens the details": "打开详情", + "Partly unmeasured": "部分未测量", + "Provisional": "初步", + "Usual high": "平常上限", + "Usual low": "平常下限", + "Your usual range appears after eight complete weeks.": "满八个完整周后显示你的平常范围。", + "Your usual week": "你的平常一周", + "vs. your usual": "对比平常" } diff --git a/Tools/translations/zh-Hant.json b/Tools/translations/zh-Hant.json index 13a74fb020..fe95056138 100644 --- a/Tools/translations/zh-Hant.json +++ b/Tools/translations/zh-Hant.json @@ -1570,5 +1570,18 @@ "NOOP uses the following third-party content under its own licence. A licence in one domain (code, data, media) is not treated as a licence in another.": "NOOP 在其自身授權下使用以下第三方內容。一個領域(程式碼、資料、媒體)的授權並不代表其他領域的授權。", "Third-party content NOOP uses under its own licence.": "NOOP 在其自身授權下使用的第三方內容。", "Workout title": "訓練標題", - "Workout title (optional)": "訓練標題(選填)" + "Workout title (optional)": "訓練標題(選填)", + "4W": "4週", + "12W": "12週", + "About usual": "與平常相當", + "Above usual": "高於平常", + "No comparison yet": "暫無對比", + "Opens the details": "打開詳情", + "Partly unmeasured": "部分未測量", + "Provisional": "初步", + "Usual high": "平常上限", + "Usual low": "平常下限", + "Your usual range appears after eight complete weeks.": "滿八個完整週後顯示你的平常範圍。", + "Your usual week": "你的平常一週", + "vs. your usual": "對比平常" }