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..6f01c95629 --- /dev/null +++ b/Strand/Screens/TrainingDesignKit.swift @@ -0,0 +1,765 @@ +import SwiftUI +import Charts +import StrandDesign +import StrandAnalytics + +// MARK: - Shared building blocks for Training Load, Cardio and Strength +// +// One visual grammar for three screens that answer different questions. The blocks take finished +// values; each screen decides what goes in them and in which order. + +/// The two training lanes and the identity each carries on every screen. +enum TrainingLane: Sendable { + case strength, cardio + + var color: Color { self == .strength ? StrandPalette.strengthColor : StrandPalette.cardioColor } + var deep: Color { self == .strength ? StrandPalette.strengthDeep : StrandPalette.cardioDeep } + var bright: Color { self == .strength ? StrandPalette.strengthBright : StrandPalette.cardioBright } + var symbol: String { self == .strength ? "figure.strengthtraining.traditional" : "heart.fill" } + var title: String { + self == .strength ? String(localized: "Strength") : String(localized: "Cardio") + } + /// Deep to base, never to bright: white text has to stay readable across the whole fill. + var fill: LinearGradient { + LinearGradient(colors: [deep, color], startPoint: .leading, endPoint: .trailing) + } +} + +// MARK: - Status pill + +/// Where a lane's last seven days sit against the wearer's usual. The words carry the state; the +/// colour only says which lane it belongs to. +enum LoadPillState: Equatable, Sendable { + case below, usual, higher, muchHigher, provisional, noComparison + + static func of(_ lane: TrainingLoadModel.Lane?, provisional: Bool = false) -> LoadPillState { + switch lane?.status?.band { + case .below: return .below + case .maintaining: return .usual + case .productive: return .higher + case .above: return .muchHigher + case nil: return provisional ? .provisional : .noComparison + } + } + + var hasComparison: Bool { self != .provisional && self != .noComparison } + + var label: String { + switch self { + case .below: return String(localized: "Below usual") + case .usual: return String(localized: "About usual") + case .higher: return String(localized: "Above usual") + case .muchHigher: return String(localized: "Well above usual") + case .provisional: return String(localized: "Provisional") + case .noComparison: return String(localized: "No comparison yet") + } + } + + var symbol: String { + switch self { + case .below: return "arrow.down.right" + case .usual: return "equal" + case .higher: return "arrow.up.right" + case .muchHigher: return "chevron.up.2" + case .provisional: return "sparkles" + case .noComparison: return "hourglass" + } + } +} + +struct LoadStatusPill: View { + let lane: TrainingLane + let state: LoadPillState + + var body: some View { + Label(state.label, systemImage: state.symbol) + .font(StrandFont.caption.weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.75) + .foregroundStyle(state.hasComparison ? StrandPalette.onDarkPrimary : StrandPalette.textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background { + if state.hasComparison { + Capsule().fill(lane.fill) + } else { + Capsule().fill(StrandPalette.surfaceInset) + } + } + } +} + +// MARK: - Formatting + +enum LoadFormat { + /// "+18 %", "−7 %", and "0 %" for a change that rounds to nothing, which has no direction. + static func signedPercent(_ value: Double) -> String { + let magnitude = Int(abs(value).rounded()) + guard magnitude > 0 else { return "0 %" } + return "\(value > 0 ? "+" : "−")\(magnitude) %" + } +} + +/// A signed percentage that counts between values instead of jumping. +struct SignedPercentCountUp: View, Animatable { + var value: Double + var animatableData: Double { + get { value } + set { value = newValue } + } + + var body: some View { + Text(verbatim: LoadFormat.signedPercent(value)).monospacedDigit() + } +} + +// MARK: - Hero + +/// A lane-tinted card surface, saturated enough to carry the lane's identity at a glance. +struct LaneHeroSurface: View { + let lane: TrainingLane + @Environment(\.colorScheme) private var scheme + + var body: some View { + let shape = RoundedRectangle(cornerRadius: NoopMetrics.cardRadius, style: .continuous) + // Lighter on a light background: the secondary text on top has to keep its contrast. + let dark = scheme == .dark + ZStack { + shape.fill(StrandPalette.surfaceRaised) + shape.fill(LinearGradient(colors: [lane.deep.opacity(dark ? 0.62 : 0.2), + lane.color.opacity(dark ? 0.22 : 0.08)], + startPoint: .topLeading, endPoint: .bottomTrailing)) + shape.fill(RadialGradient(colors: [lane.bright.opacity(dark ? 0.28 : 0.22), .clear], + center: .topTrailing, startRadius: 0, endRadius: 220)) + } + .overlay(shape.strokeBorder(lane.color.opacity(0.45), lineWidth: 1)) + .shadow(color: lane.color.opacity(0.22), radius: 16, x: 0, y: 8) + } +} + +/// A lane's headline: the change against usual, its state, the measured figure behind it, and what the +/// reading rests on. `compact` fits half the width of a phone. +struct LoadHeroCard: View { + let lane: TrainingLane + var title: String? = nil + let percent: Double? + let state: LoadPillState + var figure: String? = nil + /// Daily ratios, oldest first; gaps are left out. + var trend: [Double] = [] + var coverage: String? = nil + var caveat: String? = nil + var note: String? = nil + var compact = false + + /// Nil until the value first changes, so the card opens on the real figure rather than counting up from zero. + @State private var shownPercent: Double? + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + VStack(alignment: .leading, spacing: compact ? NoopMetrics.space2 : NoopMetrics.space3) { + header + HStack(alignment: .bottom, spacing: NoopMetrics.space3) { + VStack(alignment: .leading, spacing: 2) { + percentText + if percent != nil { + Text("vs. your usual") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + } + } + if !compact, trend.count >= 2 { sparkline } + } + if compact, trend.count >= 2 { sparkline } + if let figure { + Text(figure) + .font(StrandFont.captionNumber) + .foregroundStyle(StrandPalette.textPrimary) + .fixedSize(horizontal: false, vertical: true) + } + if let coverage { + Text(coverage) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + if let caveat { + Label { + Text(caveat).foregroundStyle(StrandPalette.textPrimary) + } icon: { + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(StrandPalette.statusWarning) + } + .font(StrandFont.caption) + .fixedSize(horizontal: false, vertical: true) + } + if let note { + Label(note, systemImage: "info.circle") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(compact ? NoopMetrics.space3 : NoopMetrics.cardPadding) + .frame(maxWidth: .infinity, alignment: .leading) + .background(LaneHeroSurface(lane: lane)) + .accessibilityElement(children: .combine) + .onChangeCompat(of: percent) { newValue in + let target = newValue ?? 0 + if shownPercent == nil { shownPercent = target } + if reduceMotion { shownPercent = target } else { withAnimation(StrandMotion.drawIn) { shownPercent = target } } + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: NoopMetrics.space2) { + HStack(spacing: NoopMetrics.space2) { + StatusBadge(symbol: lane.symbol, color: lane.color, size: compact ? 24 : 28) + Text(title ?? lane.title) + .font(StrandFont.subhead.weight(.semibold)) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(1) + Spacer(minLength: NoopMetrics.space1) + if !compact { LoadStatusPill(lane: lane, state: state) } + } + if compact { LoadStatusPill(lane: lane, state: state) } + } + } + + @ViewBuilder private var percentText: some View { + if percent != nil { + SignedPercentCountUp(value: shownPercent ?? percent ?? 0) + .font(StrandFont.number(compact ? 30 : 42, weight: .bold)) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.6) + .accessibilityLabel(Text(verbatim: LoadFormat.signedPercent(percent ?? 0))) + } else { + Text(verbatim: "—") + .font(StrandFont.number(compact ? 30 : 42, weight: .bold)) + .foregroundStyle(StrandPalette.textTertiary) + } + } + + private var sparkline: some View { + Sparkline(values: trend, gradient: Gradient(colors: [lane.deep, lane.bright]), + lineWidth: 2.5, showsArea: true, showsHead: true, showsHover: false) + .frame(height: compact ? 34 : 48) + .frame(maxWidth: .infinity) + .accessibilityHidden(true) + } +} + +// MARK: - KPI strip + +struct KPIItem: Identifiable { + let id: String + let icon: String + let value: String + let label: String + var caption: String? = nil + var info: (() -> Void)? = nil +} + +/// A row of a week's key figures. Up to four sit in one row; more wrap into rows of three. +struct KPIStrip: View { + let lane: TrainingLane + let items: [KPIItem] + + var body: some View { + let columns = items.count <= 4 ? max(items.count, 1) : 3 + NoopCard(padding: NoopMetrics.space3) { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: NoopMetrics.space2), count: columns), + alignment: .leading, spacing: NoopMetrics.space3) { + ForEach(items) { item in cell(item) } + } + } + } + + private func cell(_ item: KPIItem) -> some View { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 0) { + ZStack { + Circle().fill(lane.color.opacity(0.2)) + Image(systemName: item.icon) + .font(StrandFont.rounded(11, weight: .semibold)) + .foregroundStyle(lane.color) + } + .frame(width: 24, height: 24) + .accessibilityHidden(true) + Spacer(minLength: 0) + if let info = item.info { + Button(action: info) { + Image(systemName: "info.circle") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("What this means")) + } + } + Text(item.value) + .font(StrandFont.number(20, weight: .bold)) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.55) + Text(item.label) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(1) + .minimumScaleFactor(0.7) + Text(item.caption ?? " ") + .font(StrandFont.caption) + .foregroundStyle(lane.bright) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(verbatim: "\(item.label): \(item.value)\(item.caption.map { ", " + $0 } ?? "")")) + } +} + +// MARK: - Load over time + +enum LoadHistorySpan: String, CaseIterable, Identifiable, Sendable { + case week, fourWeeks, twelveWeeks + var id: String { rawValue } + var label: String { + switch self { + case .week: return String(localized: "7D") + case .fourWeeks: return String(localized: "4W") + case .twelveWeeks: return String(localized: "12W") + } + } +} + +/// The bars a load chart draws, worked out without SwiftUI so the windows can be tested. +enum LoadHistoryBuckets { + struct Bar: Identifiable, Equatable, Sendable { + /// First day the bar covers. + let start: String + /// Known load in the bar; nil for a day that has not happened yet. + let value: Double? + /// Part of the bar is training the data could not price, so the value is a lower bound. + let containsUnknown: Bool + /// The day or week the screen is reading. + let isSelected: Bool + var id: String { start } + } + + /// `.week`: the seven days of the week containing `readingDay`. The other spans: whole Monday weeks, + /// ending with that week, summed through `readingDay` for the week still running. + static func bars(byDay: [String: Double], unknownDays: Set, span: LoadHistorySpan, + readingDay: String) -> [Bar] { + guard let monday = WeeklyDigestEngine.mondayOfWeek(containing: readingDay) else { return [] } + switch span { + case .week: + return (0..<7).map { offset in + let day = WeeklyDigestEngine.addDays(monday, offset) + return Bar(start: day, value: day > readingDay ? nil : (byDay[day] ?? 0), + containsUnknown: unknownDays.contains(day), isSelected: day == readingDay) + } + case .fourWeeks, .twelveWeeks: + let weeks = span == .fourWeeks ? 4 : 12 + return (0.. = [] + let readingDay: String + var usualWeek: ClosedRange? = nil + + @State private var span: LoadHistorySpan = .fourWeeks + + var body: some View { + let bars = LoadHistoryBuckets.bars(byDay: byDay, unknownDays: unknownDays, span: span, + readingDay: readingDay) + NoopCard { + VStack(alignment: .leading, spacing: NoopMetrics.space3) { + HStack(alignment: .center) { + Text(title) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + Spacer(minLength: NoopMetrics.space2) + SegmentedPillControl(LoadHistorySpan.allCases, selection: $span) { $0.label } + } + chart(bars) + .frame(height: 170) + legend(bars) + } + } + } + + @ViewBuilder private func chart(_ bars: [LoadHistoryBuckets.Bar]) -> some View { + let band = span == .week ? nil : usualWeek + Chart { + if let band { + RectangleMark(yStart: .value("Usual low", band.lowerBound), + yEnd: .value("Usual high", band.upperBound)) + .foregroundStyle(lane.color.opacity(0.14)) + } + ForEach(bars) { bar in + if let value = bar.value { + BarMark(x: .value("Period", bar.start), y: .value(unit, value), width: .ratio(0.62)) + .foregroundStyle(bar.isSelected + ? AnyShapeStyle(LinearGradient(colors: [lane.bright, lane.deep], + startPoint: .top, endPoint: .bottom)) + : AnyShapeStyle(lane.color.opacity(bar.containsUnknown ? 0.3 : 0.55))) + .cornerRadius(5) + } + } + } + .chartXScale(domain: bars.map(\.start)) + .chartYAxis { + AxisMarks(position: .leading) { _ in + AxisGridLine().foregroundStyle(StrandPalette.hairline) + AxisValueLabel().font(StrandFont.caption).foregroundStyle(StrandPalette.textTertiary) + } + } + .chartXAxis { + AxisMarks { value in + AxisValueLabel { + if let start = value.as(String.self) { + Text(verbatim: axisLabel(start)) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + } + } + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(verbatim: accessibilitySummary(bars))) + } + + @ViewBuilder private func legend(_ bars: [LoadHistoryBuckets.Bar]) -> some View { + HStack(spacing: NoopMetrics.space4) { + legendDot(lane.color, title) + if span != .week { + if usualWeek != nil { + legendDot(lane.color.opacity(0.3), String(localized: "Your usual week")) + } else { + Text("Your usual range appears after eight complete weeks.") + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } + if bars.contains(where: \.containsUnknown) { + legendDot(lane.color.opacity(0.3), String(localized: "Partly unmeasured")) + } + } + } + + private func legendDot(_ color: Color, _ text: String) -> some View { + HStack(spacing: 5) { + Circle().fill(color).frame(width: 8, height: 8) + Text(text).font(StrandFont.caption).foregroundStyle(StrandPalette.textSecondary).lineLimit(1) + } + } + + private func axisLabel(_ start: String) -> String { + guard let date = WeightSeries.date(forDay: start) else { return start } + if span == .week { return date.formatted(.dateTime.weekday(.abbreviated)) } + return date.formatted(.dateTime.day().month(.defaultDigits)) + } + + private func accessibilitySummary(_ bars: [LoadHistoryBuckets.Bar]) -> String { + bars.compactMap { bar in + bar.value.map { "\(axisLabel(bar.start)): \(Int($0.rounded())) \(unit)" } + }.joined(separator: ", ") + } +} + +// MARK: - Summary tile + +/// A compact fact that opens its full card. The mini content shows enough to decide whether to open it. +struct SummaryTile: View { + let symbol: String + let tint: Color + let title: String + let headline: String + var detail: String? = nil + let action: () -> Void + @ViewBuilder var mini: () -> Mini + + var body: some View { + Button(action: action) { + VStack(alignment: .leading, spacing: NoopMetrics.space2) { + HStack(spacing: NoopMetrics.space2) { + StatusBadge(symbol: symbol, color: tint, size: 26) + Text(title) + .font(StrandFont.caption.weight(.semibold)) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(1) + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(StrandFont.caption.weight(.semibold)) + .foregroundStyle(StrandPalette.textTertiary) + } + Text(headline) + .font(StrandFont.headline) + .foregroundStyle(StrandPalette.textPrimary) + .lineLimit(2) + .minimumScaleFactor(0.8) + if let detail { + Text(detail) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(2) + } + Spacer(minLength: 0) + mini() + } + .padding(NoopMetrics.space3) + .frame(maxWidth: .infinity, minHeight: 112, alignment: .topLeading) + .background(FrostedCardSurface(tint: tint, cornerRadius: NoopMetrics.groupedRadius)) + .contentShape(RoundedRectangle(cornerRadius: NoopMetrics.groupedRadius, style: .continuous)) + } + .buttonStyle(.plain) + .strandPressable(cornerRadius: NoopMetrics.groupedRadius) + .accessibilityElement(children: .combine) + .accessibilityHint(Text("Opens the details")) + } +} + +// MARK: - Explainers + +struct ExplainerItem: Identifiable { + let id: String + let symbol: String + let title: String + let subtitle: String + let content: () -> AnyView + + init(id: String, symbol: String, title: String, subtitle: String, text: String) { + self.id = id; self.symbol = symbol; self.title = title; self.subtitle = subtitle + self.content = { + AnyView(Text(text) + .font(StrandFont.body) + .foregroundStyle(StrandPalette.textSecondary) + .fixedSize(horizontal: false, vertical: true)) + } + } + + init(id: String, symbol: String, title: String, subtitle: String, + @ViewBuilder content: @escaping () -> Content) { + self.id = id; self.symbol = symbol; self.title = title; self.subtitle = subtitle + self.content = { AnyView(content()) } + } +} + +/// How the screen's figures are made, one tap away instead of spelled out between the figures. +struct ExplainerRows: View { + var header: String = String(localized: "How it works") + let items: [ExplainerItem] + @State private var open: String? + + var body: some View { + VStack(alignment: .leading, spacing: NoopMetrics.gap) { + Text(header) + .font(StrandFont.title2) + .foregroundStyle(StrandPalette.textPrimary) + NoopCard(padding: 0) { + VStack(spacing: 0) { + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + if index > 0 { Divider().overlay(StrandPalette.hairline).padding(.leading, 56) } + Button { open = item.id } label: { row(item) } + .buttonStyle(.plain) + } + } + } + } + .sheet(item: Binding(get: { open.flatMap { id in items.first { $0.id == id } }.map(SheetTarget.init) }, + set: { open = $0?.id })) { target in + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: NoopMetrics.space3) { + Text(target.item.title) + .font(StrandFont.title2) + .foregroundStyle(StrandPalette.textPrimary) + target.item.content() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(NoopMetrics.screenPadding) + } + .background(StrandPalette.surfaceBase.ignoresSafeArea()) + .toolbar { + ToolbarItem(placement: .confirmationAction) { Button("Done") { open = nil } } + } + } + } + } + + private struct SheetTarget: Identifiable { + let item: ExplainerItem + var id: String { item.id } + } + + private func row(_ item: ExplainerItem) -> some View { + HStack(spacing: NoopMetrics.space3) { + StatusBadge(symbol: item.symbol, color: StrandPalette.metricCyan, size: 32) + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .font(StrandFont.subhead.weight(.semibold)) + .foregroundStyle(StrandPalette.textPrimary) + Text(item.subtitle) + .font(StrandFont.caption) + .foregroundStyle(StrandPalette.textSecondary) + .lineLimit(2) + } + Spacer(minLength: NoopMetrics.space2) + Image(systemName: "chevron.right") + .font(StrandFont.caption.weight(.semibold)) + .foregroundStyle(StrandPalette.textTertiary) + } + .padding(.horizontal, NoopMetrics.space3) + .padding(.vertical, NoopMetrics.space3) + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + } +} + +// MARK: - Week control + +/// The week stepper and history window shared by Cardio and Strength. +struct TrainingWeekControl: View { + let overline: String + let rangeText: String + let canGoBack: Bool + let canGoForward: Bool + let step: (Int) -> Void + let ranges: [Range] + @Binding var selectedRange: Range + let rangeLabel: (Range) -> String + + var body: some View { + HStack(alignment: .center, spacing: NoopMetrics.space2) { + VStack(alignment: .leading, spacing: 2) { + Text(overline).strandOverline() + Text(rangeText) + .font(StrandFont.title2) + .foregroundStyle(StrandPalette.textPrimary) + .monospacedDigit() + .lineLimit(1) + .minimumScaleFactor(0.7) + } + Spacer(minLength: NoopMetrics.space2) + Menu { + ForEach(ranges) { range in + Button { + selectedRange = range + } label: { + if range == selectedRange { Label(rangeLabel(range), systemImage: "checkmark") } + else { Text(rangeLabel(range)) } + } + } + } label: { + Label(rangeLabel(selectedRange), systemImage: "calendar") + .font(StrandFont.caption.weight(.semibold)) + .foregroundStyle(StrandPalette.textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(StrandPalette.surfaceInset, in: Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("History window")) + stepButton("chevron.left", enabled: canGoBack, delta: -1, label: String(localized: "Previous week")) + stepButton("chevron.right", enabled: canGoForward, delta: 1, label: String(localized: "Next week")) + } + } + + private func stepButton(_ symbol: String, enabled: Bool, delta: Int, label: String) -> some View { + Button { step(delta) } label: { + Image(systemName: symbol) + .font(StrandFont.caption.weight(.bold)) + .frame(width: 30, height: 30) + .background(StrandPalette.surfaceInset, in: Circle()) + } + .buttonStyle(.plain) + .foregroundStyle(enabled ? StrandPalette.accent : StrandPalette.textTertiary) + .disabled(!enabled) + .accessibilityLabel(Text(label)) + } +} + +// MARK: - Layout + +/// Side by side where there is room for both, stacked on a phone. +struct AdaptiveTwoColumn: View { + var minimumWidth: CGFloat = 700 + @ViewBuilder let leading: () -> Leading + @ViewBuilder let trailing: () -> Trailing + + var body: some View { + ViewThatFits(in: .horizontal) { + HStack(alignment: .top, spacing: NoopMetrics.gap) { + leading().frame(maxWidth: .infinity) + trailing().frame(maxWidth: .infinity) + } + .frame(minWidth: minimumWidth) + VStack(spacing: NoopMetrics.gap) { + leading() + trailing() + } + } + } +} + +#if DEBUG +#Preview("Training design kit") { + ScrollView { + VStack(spacing: NoopMetrics.gap) { + HStack(spacing: NoopMetrics.gap) { + LoadHeroCard(lane: .strength, percent: 58, state: .muchHigher, + figure: "10 working sets · 7.4 weighted", trend: [0.8, 1.0, 1.2, 1.1, 1.5, 1.6], + coverage: "8 of 10 sets rated", compact: true) + LoadHeroCard(lane: .cardio, percent: nil, state: .noComparison, + figure: "at least 372 TRIMP", coverage: "3 of 4 sessions complete", + caveat: "One session has no usable heart rate", compact: true) + } + LoadHeroCard(lane: .cardio, percent: -12, state: .usual, figure: "508 TRIMP", + trend: [1.2, 1.1, 0.9, 1.0, 0.95, 0.88], coverage: "All 4 sessions measured") + KPIStrip(lane: .cardio, items: [ + KPIItem(id: "s", icon: "figure.run", value: "5", label: "Sessions"), + KPIItem(id: "t", icon: "clock.fill", value: "5h 32m", label: "Moving time", caption: "usual 3–4h"), + KPIItem(id: "d", icon: "point.topleft.down.to.point.bottomright.curvepath", value: "41 km", label: "Distance"), + KPIItem(id: "k", icon: "flame.fill", value: "2,480", label: "kcal"), + ]) + LoadHistoryChart(lane: .strength, title: "Strength load", unit: "sets", + byDay: ["2025-09-01": 4, "2025-09-03": 5, "2025-09-10": 7, "2025-09-15": 6], + readingDay: "2025-09-17", usualWeek: 8...14) + HStack(spacing: NoopMetrics.gap) { + SummaryTile(symbol: "arrow.up.right", tint: StrandPalette.statusPositive, title: "Adaptation", + headline: "Productive development", action: {}) { EmptyView() } + SummaryTile(symbol: "moon.zzz.fill", tint: StrandPalette.metricCyan, title: "Recovery", + headline: "Holding", detail: "1 of 7 nights flagged", action: {}) { EmptyView() } + } + ExplainerRows(items: [ + ExplainerItem(id: "a", symbol: "function", title: "How it is calculated", + subtitle: "TRIMP and your usual range", text: "Explanation."), + ]) + } + .padding() + } + .background(StrandPalette.surfaceBase) +} +#endif diff --git a/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..ecdce785ad 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 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": "對比平常" }