From f2c7290ff1bd37b64c4648f7d6be2babc163744b Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Tue, 18 Aug 2026 13:27:36 +1200 Subject: [PATCH] feat(trends): CTL/ATL/TSB training-load card (iOS/macOS + Android) Surfaces the long-horizon training-load model (TrainingLoadEngine) as a card on the Trends screen, all three platforms. Overlays chronic load (CTL, 42-day fitness) and acute load (ATL, 7-day fatigue) on one shared y-scale so the gap between the lines reads as the TSB / form (CTL - ATL); form is the headline number + a footer stat, with CTL/ATL/Days/Form stats below. - Descriptive only: CTL/ATL/TSB never feed the Charge/Readiness score. Load is daily Effort/strain (DailyMetric.strain), not TRIMP. - Models the FULL history, not the range window (chronic load is a 42-day horizon). Honest empty state names how many consecutive Effort days are still needed; a "building" subtitle shows N of 42 while the baseline calibrates. - Calls TrainingLoadEngine.evaluate directly, not the paired evaluateWithTrainingLoad, so it never runs the unused Readiness synthesis. - Apple: isolated card file to keep its type-check cost out of TrendsView. Android: shared-scale two-line Canvas. - Localized de/es/fr/pt-PT on both platforms (xcstrings + strings.xml); CTL/ATL/TSB added to the i18n audit UNIVERSAL acronym set (like HRV). --- Strand/Resources/Localizable.xcstrings | 306 ++++++++++++++++++ Strand/Screens/TrainingLoadCard.swift | 157 +++++++++ Strand/Screens/TrendsView.swift | 9 +- Tools/i18n_audit.py | 2 + Tools/i18n_echo_baseline.txt | 4 +- Tools/i18n_extra_locale_baseline.txt | 10 +- .../main/java/com/noop/ui/TrainingLoadCard.kt | 139 ++++++++ .../src/main/java/com/noop/ui/TrendsScreen.kt | 11 +- .../app/src/main/res/values-de/strings.xml | 8 + .../app/src/main/res/values-es/strings.xml | 8 + .../app/src/main/res/values-fr/strings.xml | 8 + .../app/src/main/res/values-pl/strings.xml | 8 + .../src/main/res/values-pt-rPT/strings.xml | 8 + android/app/src/main/res/values/strings.xml | 8 + 14 files changed, 675 insertions(+), 11 deletions(-) create mode 100644 Strand/Screens/TrainingLoadCard.swift create mode 100644 android/app/src/main/java/com/noop/ui/TrainingLoadCard.kt diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 01340da20d..9dc702f252 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -206844,6 +206844,312 @@ } } } + }, + "Training Load": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Trainingslast" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Carga de entrenamiento" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Charge d'entraînement" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Carga de treino" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Obciążenie treningowe" + } + } + } + }, + "CTL · Fitness": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "CTL · Kondition" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "CTL · Forma física" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "CTL · Forme" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "CTL · Forma física" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "CTL · Kondycja" + } + } + } + }, + "ATL · Fatigue": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "ATL · Ermüdung" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "ATL · Fatiga" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "ATL · Fatigue" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "ATL · Fadiga" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "ATL · Zmęczenie" + } + } + } + }, + "42-day fitness vs 7-day fatigue": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "42-Tage-Fitness vs. 7-Tage-Ermüdung" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Forma de 42 días frente a fatiga de 7 días" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Forme sur 42 jours vs fatigue sur 7 jours" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Forma de 42 dias vs fadiga de 7 dias" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kondycja 42-dniowa vs zmęczenie 7-dniowe" + } + } + } + }, + "Building — %lld of %lld days": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird aufgebaut — %1$lld von %2$lld Tagen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Generando — %1$lld de %2$lld días" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En cours — %1$lld sur %2$lld jours" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A construir — %1$lld de %2$lld dias" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Budowanie — %1$lld z %2$lld dni" + } + } + } + }, + "Chronic vs acute load": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Chronische vs. akute Last" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Carga crónica frente a aguda" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Charge chronique vs aiguë" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Carga crónica vs aguda" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Obciążenie przewlekłe vs ostre" + } + } + } + }, + "Needs %lld+ consecutive days of Effort to begin. %lld so far.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Benötigt %1$lld+ aufeinanderfolgende Tage mit Anstrengung, um zu starten. Bisher %2$lld." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Necesita %1$lld+ días consecutivos de Esfuerzo para empezar. %2$lld hasta ahora." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nécessite %1$lld+ jours consécutifs d'Effort pour démarrer. %2$lld jusqu'ici." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Precisa de %1$lld+ dias consecutivos de Esforço para começar. %2$lld até agora." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wymaga %1$lld+ kolejnych dni Wysiłku, aby zacząć. Na razie %2$lld." + } + } + } + }, + "Form": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Tagesform" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Forma" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Forme" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Forma" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Forma" + } + } + } + }, + "Training load: chronic vs acute": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Trainingslast: chronisch vs. akut" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Carga de entrenamiento: crónica frente a aguda" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Charge d'entraînement : chronique vs aiguë" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Carga de treino: crónica vs aguda" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Obciążenie treningowe: przewlekłe vs ostre" + } + } + } } }, "version": "1.0" diff --git a/Strand/Screens/TrainingLoadCard.swift b/Strand/Screens/TrainingLoadCard.swift new file mode 100644 index 0000000000..a6c885af15 --- /dev/null +++ b/Strand/Screens/TrainingLoadCard.swift @@ -0,0 +1,157 @@ +import Foundation +import SwiftUI +import Charts +import StrandDesign +import StrandAnalytics +import WhoopStore + +// MARK: - Training Load card (CTL / ATL / TSB) +// +// The first UI surface for the long-horizon training-load model (TrainingLoadEngine, added with the +// paired `ReadinessEngine.evaluateWithTrainingLoad`). It overlays chronic load (CTL, the 42-day +// fitness proxy) and acute load (ATL, the 7-day fatigue proxy); the gap between the two lines IS the +// TSB / "form" (CTL − ATL), surfaced as the headline number and a footer stat. +// +// Descriptive only: CTL/ATL/TSB never feed the Readiness level or any score, and the loads are NOOP's +// daily Effort/strain — NOT TRIMP. Long-horizon by nature, so the card models the full history rather +// than the Trends range window (14+ contiguous days are needed before anything is drawn). +// +// Isolated in its own file on purpose: TrendsView already sits near the iOS type-check budget, so this +// keeps its own inference cost out of that body. +struct TrainingLoadCard: View { + let days: [DailyMetric] + + // yyyy-MM-dd → Date (en_US_POSIX, UTC) — same keying TrendsView uses so the x-axis matches. + private static let dayParser: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(identifier: "UTC") + f.dateFormat = "yyyy-MM-dd" + return f + }() + + private struct Row: Identifiable { + let date: Date + let ctl: Double + let atl: Double + var id: Date { date } + } + + /// One modelled point per day of the contiguous suffix the engine returned. + private var rows: [Row] { + result.points.compactMap { p in + guard let d = Self.dayParser.date(from: p.day) else { return nil } + return Row(date: d, ctl: p.chronicLoad, atl: p.acuteLoad) + } + } + + /// Model straight from the training-load engine — NOT the paired `evaluateWithTrainingLoad`, which + /// would also run the full Readiness synthesis this card never uses. `DailyMetric.strain` is the load. + private var result: TrainingLoadEngine.Result { + let loads = days.map { TrainingLoadEngine.DailyLoad(day: $0.day, load: $0.strain) } + return TrainingLoadEngine.evaluate(days: loads) + } + + private static let established = TrainingLoadEngine.Configuration.standard.establishedDays + private static let minimum = TrainingLoadEngine.Configuration.standard.minimumDays + + private func fmt(_ v: Double) -> String { String(format: "%.1f", v) } + private func signed(_ v: Double) -> String { String(format: "%+.1f", v) } + + var body: some View { + let tl = result + if !tl.isAvailable { + unavailableCard(contiguousDays: tl.contiguousDays) + } else { + let latest = tl.points.last + ChartCard( + title: "Training Load", + subtitle: subtitle(for: tl), + trailing: latest.map { signed($0.balance) }, + height: NoopMetrics.chartHeight, + chart: { + VStack(alignment: .leading, spacing: NoopMetrics.space2) { + legend + chart + } + }, + footer: { + ChartFooter([ + ("CTL", latest.map { fmt($0.chronicLoad) } ?? "—"), + ("ATL", latest.map { fmt($0.acuteLoad) } ?? "—"), + ("Form", latest.map { signed($0.balance) } ?? "—"), + ("Days", "\(tl.contiguousDays)"), + ]) + } + ) + } + } + + // Two overlaid lines: CTL (fitness) and ATL (fatigue). The vertical gap between them is the form. + private var chart: some View { + // Floor at 1 (matching the Android `fold(1.0)` twin): an all-rest window of zero loads would + // otherwise make the y-domain `0...0`, which Swift Charts renders as a degenerate/empty scale. + let maxY = max(rows.map { max($0.ctl, $0.atl) }.max() ?? 1, 1) + return Chart { + ForEach(rows) { r in + LineMark(x: .value("Day", r.date), y: .value("CTL", r.ctl), + series: .value("Series", "CTL")) + .foregroundStyle(StrandPalette.gold) + .interpolationMethod(.catmullRom) + } + ForEach(rows) { r in + LineMark(x: .value("Day", r.date), y: .value("ATL", r.atl), + series: .value("Series", "ATL")) + .foregroundStyle(StrandPalette.strain100) + .interpolationMethod(.catmullRom) + } + } + .chartYScale(domain: 0...(maxY * 1.08)) + .chartYAxis { AxisMarks(position: .leading) } + .accessibilityLabel(Text("Training load: chronic vs acute")) + } + + private var legend: some View { + HStack(spacing: NoopMetrics.space2 * 2) { + legendDot(color: StrandPalette.gold, label: "CTL · Fitness") + legendDot(color: StrandPalette.strain100, label: "ATL · Fatigue") + Spacer() + } + } + + private func legendDot(color: Color, label: LocalizedStringKey) -> some View { + HStack(spacing: NoopMetrics.space2) { + Circle().fill(color).frame(width: 8, height: 8) + Text(label).font(StrandFont.footnote).foregroundStyle(StrandPalette.textTertiary) + } + } + + private func subtitle(for tl: TrainingLoadEngine.Result) -> String { + switch tl.state { + case .established: + return String(localized: "42-day fitness vs 7-day fatigue") + case .building: + return String(localized: "Building — \(tl.contiguousDays) of \(Self.established) days") + case .unavailable: + return "" + } + } + + // Honest empty state: name exactly how many consecutive Effort days are still needed. + private func unavailableCard(contiguousDays: Int) -> some View { + ChartCard( + title: "Training Load", + subtitle: String(localized: "Chronic vs acute load"), + chart: { + VStack(spacing: NoopMetrics.space2) { + Text("Needs \(Self.minimum)+ consecutive days of Effort to begin. \(contiguousDays) so far.") + .font(StrandFont.subhead) + .foregroundStyle(StrandPalette.textTertiary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + }, + footer: { EmptyView() } + ) + } +} diff --git a/Strand/Screens/TrendsView.swift b/Strand/Screens/TrendsView.swift index 897f48b25b..7ce0f23695 100644 --- a/Strand/Screens/TrendsView.swift +++ b/Strand/Screens/TrendsView.swift @@ -308,10 +308,15 @@ struct TrendsView: View { .staggeredAppear(index: 3) smallMultiples(hrv: hrv, rhr: rhr, strain: strain) .staggeredAppear(index: 4) - yearStrip + // Long-horizon training load (CTL/ATL/TSB). Uses the FULL history, not the + // range window — chronic load is inherently a 42-day horizon. Self-hides its + // chart behind an honest "needs N more days" state until enough history exists. + TrainingLoadCard(days: repo.days) .staggeredAppear(index: 5) - exportReportRow + yearStrip .staggeredAppear(index: 6) + exportReportRow + .staggeredAppear(index: 7) } } } diff --git a/Tools/i18n_audit.py b/Tools/i18n_audit.py index 1e416e97f0..88e6d3ac6c 100644 --- a/Tools/i18n_audit.py +++ b/Tools/i18n_audit.py @@ -46,6 +46,8 @@ UNIVERSAL = { "", "-", "–", "—", "·", "•", "✓", "→", "↔", "NOOP", "bpm", "BPM", "HRV", "SpO2", "SpO₂", "OK", "ID", + # Training-load acronyms — universal training-science terms, identical in every language (like HRV). + "CTL", "ATL", "TSB", } # A bare printf/String.format conversion specifier, e.g. "%.1f" or "%02d" — a diff --git a/Tools/i18n_echo_baseline.txt b/Tools/i18n_echo_baseline.txt index ddafb0afa2..1283950cba 100644 --- a/Tools/i18n_echo_baseline.txt +++ b/Tools/i18n_echo_baseline.txt @@ -10,7 +10,7 @@ Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings fr 1 Packages/StrandDesign/Sources/StrandDesign/Resources/Localizable.xcstrings pt-PT 1 Strand/Resources/Localizable.xcstrings de 16 Strand/Resources/Localizable.xcstrings es 20 -Strand/Resources/Localizable.xcstrings fr 16 +Strand/Resources/Localizable.xcstrings fr 17 Strand/Resources/Localizable.xcstrings it 15 Strand/Resources/Localizable.xcstrings pl 17 Strand/Resources/Localizable.xcstrings pt-PT 11 @@ -19,7 +19,7 @@ Strand/Resources/Localizable.xcstrings zh-Hans 8 Strand/Resources/Localizable.xcstrings zh-Hant 7 android/app/src/main/res/values-de/strings.xml de 16 android/app/src/main/res/values-es/strings.xml es 42 -android/app/src/main/res/values-fr/strings.xml fr 12 +android/app/src/main/res/values-fr/strings.xml fr 13 android/app/src/main/res/values-pl/strings.xml pl 11 android/app/src/main/res/values-pt-rPT/strings.xml pt-rPT 8 android/app/src/main/res/values-zh/strings.xml zh 16 diff --git a/Tools/i18n_extra_locale_baseline.txt b/Tools/i18n_extra_locale_baseline.txt index d549c20bf0..02784c01c1 100644 --- a/Tools/i18n_extra_locale_baseline.txt +++ b/Tools/i18n_extra_locale_baseline.txt @@ -14,8 +14,8 @@ NOOPWatch/Localizable.xcstrings:zh-Hant 0 NOOPWatchComplications/Localizable.xcstrings:it 25 NOOPWatchComplications/Localizable.xcstrings:zh-Hans 25 NOOPWatchComplications/Localizable.xcstrings:zh-Hant 25 -Strand/Resources/Localizable.xcstrings:it 168 -Strand/Resources/Localizable.xcstrings:ru 145 -Strand/Resources/Localizable.xcstrings:zh-Hans 118 -Strand/Resources/Localizable.xcstrings:zh-Hant 168 -values-zh/strings.xml 42 +Strand/Resources/Localizable.xcstrings:it 177 +Strand/Resources/Localizable.xcstrings:ru 154 +Strand/Resources/Localizable.xcstrings:zh-Hans 127 +Strand/Resources/Localizable.xcstrings:zh-Hant 177 +values-zh/strings.xml 50 diff --git a/android/app/src/main/java/com/noop/ui/TrainingLoadCard.kt b/android/app/src/main/java/com/noop/ui/TrainingLoadCard.kt new file mode 100644 index 0000000000..d8695edf66 --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/TrainingLoadCard.kt @@ -0,0 +1,139 @@ +package com.noop.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.material3.Text +import com.noop.R +import com.noop.analytics.TrainingLoadEngine +import com.noop.data.DailyMetric +import kotlin.math.max + +/** + * Training Load card (CTL / ATL / TSB) — the first UI surface for [TrainingLoadEngine], twin of the + * Apple `TrainingLoadCard`. Overlays chronic load (CTL, 42-day fitness proxy) and acute load (ATL, + * 7-day fatigue proxy) on ONE shared y-scale, so the vertical gap between the lines reads as the TSB / + * "form" (CTL − ATL), surfaced as the headline number + a footer stat. + * + * Descriptive only: CTL/ATL/TSB never feed the Charge/Readiness score, and the load is NOOP's daily + * Effort/strain — not TRIMP. Long-horizon by nature, so it models the full history (not the range + * window) and shows an honest "needs N more days" state until 14+ contiguous days exist. + */ +@Composable +fun TrainingLoadCard(days: List, modifier: Modifier = Modifier) { + // Model straight from the training-load engine — NOT the paired evaluateWithTrainingLoad, which + // would also run the full Readiness synthesis this card never uses. DailyMetric.strain is the load. + val loads = days.map { TrainingLoadEngine.DailyLoad(it.day, it.strain) } + val result = TrainingLoadEngine.evaluate(loads) + val latest = result.points.lastOrNull() + + val subtitle = when (result.state) { + TrainingLoadEngine.State.ESTABLISHED -> stringResource(R.string.trends_tl_established) + TrainingLoadEngine.State.BUILDING -> + stringResource(R.string.trends_tl_building, result.contiguousDays, TrainingLoadEngine.standard.establishedDays) + TrainingLoadEngine.State.UNAVAILABLE -> stringResource(R.string.trends_tl_unavailable) + } + + NoopCard(modifier = modifier) { + Column(verticalArrangement = Arrangement.spacedBy(Metrics.space12)) { + Row(verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f)) { + Overline(text = stringResource(R.string.trends_training_load)) + Text(subtitle, style = NoopType.footnote, color = Palette.textTertiary) + } + if (latest != null) { + Text(signed(latest.balance), style = NoopType.bodyNumber, color = Palette.textPrimary) + } + } + + if (!result.isAvailable) { + Text( + stringResource( + R.string.trends_tl_needs, + TrainingLoadEngine.standard.minimumDays, + result.contiguousDays, + ), + style = NoopType.body, + color = Palette.textTertiary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(vertical = Metrics.space12), + ) + } else { + Row(horizontalArrangement = Arrangement.spacedBy(Metrics.space12)) { + LegendDot(Palette.gold, stringResource(R.string.trends_tl_legend_ctl)) + LegendDot(Palette.metricAmber, stringResource(R.string.trends_tl_legend_atl)) + } + TwoLineChart(result.points, Modifier.fillMaxWidth().height(Metrics.chartHeight)) + Row(Modifier.fillMaxWidth()) { + // CTL/ATL are universal training-science acronyms (like HRV/SpO2) — same in every language. + Stat("CTL", latest?.let { fmt(it.chronicLoad) } ?: "—") + Stat("ATL", latest?.let { fmt(it.acuteLoad) } ?: "—") + Stat(stringResource(R.string.trends_tl_form), latest?.let { signed(it.balance) } ?: "—") + Stat(stringResource(R.string.trends_days), "${result.contiguousDays}") + } + } + } + } +} + +@Composable +private fun TwoLineChart(points: List, modifier: Modifier) { + val ctl = Palette.gold + val atl = Palette.metricAmber + // Shared y-scale over BOTH series so the gap between the lines is a faithful TSB. + val maxY = points.fold(1.0) { acc, p -> max(acc, max(p.chronicLoad, p.acuteLoad)) } + Canvas(modifier) { + val n = points.size + if (n < 2) return@Canvas + val w = size.width + val h = size.height + fun path(value: (TrainingLoadEngine.Point) -> Double): Path { + val p = Path() + points.forEachIndexed { i, pt -> + val x = w * i / (n - 1) + val y = h - (value(pt) / maxY).toFloat() * h + if (i == 0) p.moveTo(x, y) else p.lineTo(x, y) + } + return p + } + drawPath(path { it.chronicLoad }, color = ctl, style = Stroke(width = 3f)) + drawPath(path { it.acuteLoad }, color = atl, style = Stroke(width = 3f)) + } +} + +@Composable +private fun LegendDot(color: Color, label: String) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Metrics.space4)) { + Spacer(Modifier.size(8.dp).clip(CircleShape).background(color)) + Text(label, style = NoopType.footnote, color = Palette.textTertiary) + } +} + +@Composable +private fun androidx.compose.foundation.layout.RowScope.Stat(label: String, value: String) { + Column(Modifier.weight(1f)) { + Text(label, style = NoopType.footnote, color = Palette.textTertiary) + Text(value, style = NoopType.captionNumber, color = Palette.textSecondary) + } +} + +private fun fmt(v: Double): String = String.format(java.util.Locale.US, "%.1f", v) +private fun signed(v: Double): String = String.format(java.util.Locale.US, "%+.1f", v) diff --git a/android/app/src/main/java/com/noop/ui/TrendsScreen.kt b/android/app/src/main/java/com/noop/ui/TrendsScreen.kt index efeff2d95b..ddbe5df3a8 100644 --- a/android/app/src/main/java/com/noop/ui/TrendsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TrendsScreen.kt @@ -309,9 +309,16 @@ fun TrendsScreen(vm: AppViewModel) { } } + // --- Long-horizon training load (CTL/ATL/TSB). Full history, not the range window — chronic + // load is inherently a 42-day horizon. Shows an honest "needs N more days" state until enough + // contiguous Effort history exists. Twin of the Apple TrainingLoadCard. --- + item { + TrainingLoadCard(days = days, modifier = Modifier.staggeredAppear(index = 5)) + } + // --- Recovery history strip (stands in for the macOS YearHeatStrip) --- item { - Column(modifier = Modifier.staggeredAppear(index = 5)) { + Column(modifier = Modifier.staggeredAppear(index = 6)) { RecoveryHistoryCard(days = days, range = range) } } @@ -320,7 +327,7 @@ fun TrendsScreen(vm: AppViewModel) { // TrendsView.exportReportRow footer; the same composable Settings hosts, so both surfaces // offer it. Routed through NoopButton like every other CTA (no gold). --- item { - Column(modifier = Modifier.staggeredAppear(index = 6)) { + Column(modifier = Modifier.staggeredAppear(index = 7)) { TrendsReportExportSection(vm) } } diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 8beaa58198..19f87b0240 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2131,4 +2131,12 @@ Als Anfänger-Standard nicht empfohlen, außer Ziel ist, Müdigkeit zu durchbrechen. Beruhige mich + Trainingslast + CTL · Kondition + ATL · Ermüdung + 42-Tage-Fitness vs. 7-Tage-Ermüdung + Wird aufgebaut — %1$d von %2$d Tagen + Chronische vs. akute Last + Benötigt %1$d+ aufeinanderfolgende Tage mit Anstrengung, um zu starten. Bisher %2$d. + Tagesform diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 00589325f0..88595fc8b5 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2117,4 +2117,12 @@ No recomendado como valor por defecto para principiantes salvo que el objetivo sea superar la somnolencia. Cálmame + Carga de entrenamiento + CTL · Forma física + ATL · Fatiga + Forma de 42 días frente a fatiga de 7 días + Generando — %1$d de %2$d días + Carga crónica frente a aguda + Necesita %1$d+ días consecutivos de Esfuerzo para empezar. %2$d hasta ahora. + Forma diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 84bcd6e1b3..938ce5c2a4 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2116,4 +2116,12 @@ Non recommandé comme défaut débutant sauf si le but est de vaincre la somnolence. Calmez-moi + Charge d’entraînement + CTL · Forme + ATL · Fatigue + Forme sur 42 jours vs fatigue sur 7 jours + En cours — %1$d sur %2$d jours + Charge chronique vs aiguë + Nécessite %1$d+ jours consécutifs d’Effort pour démarrer. %2$d jusqu’ici. + Forme diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index e3812992f9..aa61e377ae 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -2110,4 +2110,12 @@ Niezalecane jako domyślne dla początkujących, chyba że celem jest przebicie senności. Uspokój mnie + Obciążenie treningowe + CTL · Kondycja + ATL · Zmęczenie + Kondycja 42-dniowa vs zmęczenie 7-dniowe + Budowanie — %1$d z %2$d dni + Obciążenie przewlekłe vs ostre + Wymaga %1$d+ kolejnych dni Wysiłku, aby zacząć. Na razie %2$d. + Forma diff --git a/android/app/src/main/res/values-pt-rPT/strings.xml b/android/app/src/main/res/values-pt-rPT/strings.xml index c1a9f0b4a7..1df2e91e56 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -2110,4 +2110,12 @@ Não recomendado como padrão para principiantes, salvo se o objetivo for ultrapassar a sonolência. Acalma-me + Carga de treino + CTL · Forma física + ATL · Fadiga + Forma de 42 dias vs fadiga de 7 dias + A construir — %1$d de %2$d dias + Carga crónica vs aguda + Precisa de %1$d+ dias consecutivos de Esforço para começar. %2$d até agora. + Forma diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index bbaf90223e..efb38e47d4 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2143,4 +2143,12 @@ Not recommended as a beginner default unless the goal is to overcome drowsiness. Calm me + Training Load + CTL · Fitness + ATL · Fatigue + 42-day fitness vs 7-day fatigue + Building — %1$d of %2$d days + Chronic vs acute load + Needs %1$d+ consecutive days of Effort to begin. %2$d so far. + Form