From a8c15499daae0f18cbe62ed8c1c9a85403bb71a7 Mon Sep 17 00:00:00 2001 From: kavemang Date: Tue, 15 Sep 2026 16:25:31 -0500 Subject: [PATCH] stress: explain activity-masked hours (#2125) --- Strand/Liquid/LiquidTodayView.swift | 18 ++++- Strand/Resources/Localizable.xcstrings | 18 +++++ Strand/Screens/StressView.swift | 17 ++++- Strand/Screens/TodayView.swift | 12 +++- Tools/i18n_extra_locale_baseline.txt | 2 +- .../src/main/java/com/noop/ui/StressScreen.kt | 67 ++++++++++++++++--- .../src/main/java/com/noop/ui/TodayScreen.kt | 9 ++- .../com/noop/widget/StressWidgetProducer.kt | 36 ++++++---- .../app/src/main/res/values-de/strings.xml | 7 ++ .../app/src/main/res/values-es/strings.xml | 7 ++ .../app/src/main/res/values-fr/strings.xml | 7 ++ .../app/src/main/res/values-pl/strings.xml | 9 +++ .../src/main/res/values-pt-rPT/strings.xml | 7 ++ .../app/src/main/res/values-ru/strings.xml | 9 +++ .../app/src/main/res/values-zh/strings.xml | 6 ++ android/app/src/main/res/values/strings.xml | 7 ++ 16 files changed, 208 insertions(+), 30 deletions(-) diff --git a/Strand/Liquid/LiquidTodayView.swift b/Strand/Liquid/LiquidTodayView.swift index 839caab4f2..19361f5291 100644 --- a/Strand/Liquid/LiquidTodayView.swift +++ b/Strand/Liquid/LiquidTodayView.swift @@ -83,6 +83,7 @@ struct LiquidTodayView: View { // cheap heart-rate fingerprint and memoises, so the widget, this shell and the other Today view all // share one computation rather than scoring the day three times. @State private var hostedStressHours: [DaytimeStress.HourPoint] = [] + @State private var hostedStressActivityMaskedHours = 0 // sheets / expanders @State private var guideSection: ScoreSection? @@ -806,6 +807,12 @@ struct LiquidTodayView: View { .foregroundStyle(StrandPalette.textTertiary) .frame(maxWidth: .infinity, minHeight: 60, alignment: .center) } + if let maskedCaption = stressActivityMaskedHoursCaption(hostedStressActivityMaskedHours) { + Text(maskedCaption) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } } } case .asleepDuration: AsleepDurationCard(data: AsleepDurationData.build(days: repo.days)) @@ -1803,9 +1810,14 @@ struct LiquidTodayView: View { } // #2040: and today's stress, on the same "only when hosted" rule. - hostedStressHours = HostedCardPrefs.decodeEnabled(hostedCardsRaw).contains(.stressToday) - ? (await StressDayCurve.today(repo: repo)?.result.timeline ?? []) - : [] + if HostedCardPrefs.decodeEnabled(hostedCardsRaw).contains(.stressToday) { + let result = await StressDayCurve.today(repo: repo)?.result + hostedStressHours = result?.timeline ?? [] + hostedStressActivityMaskedHours = result?.activityMaskedHours ?? 0 + } else { + hostedStressHours = [] + hostedStressActivityMaskedHours = 0 + } // First load done — bring the hero gauges + sky to life now the launch churn has settled. if !dataLoaded { withAnimation(.easeIn(duration: 0.4)) { dataLoaded = true } } diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 83578f0046..040709c4d2 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -1,6 +1,18 @@ { "sourceLanguage": "en", "strings": { + "%lld hours excluded — you were moving.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "%lld Stunden ausgeschlossen — du warst in Bewegung."}}, "es": {"stringUnit": {"state": "translated", "value": "Se excluyeron %lld horas porque estabas en movimiento."}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld heures exclues — vous étiez en mouvement."}}, "it": {"stringUnit": {"state": "translated", "value": "%lld ore escluse — eri in movimento."}}, "pl": {"stringUnit": {"state": "translated", "value": "Wykryty ruch — wykluczone godziny: %lld."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Foram excluídas %lld horas — estiveste em movimento."}}, "ru": {"stringUnit": {"state": "translated", "value": "Движение: исключённые часы — %lld."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "因你当时在活动,已排除 %lld 小时。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "因你當時在活動,已排除 %lld 小時。"}} + } }, + "1 hour excluded — you were moving.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "1 Stunde ausgeschlossen — du warst in Bewegung."}}, "es": {"stringUnit": {"state": "translated", "value": "Se excluyó 1 hora porque estabas en movimiento."}}, "fr": {"stringUnit": {"state": "translated", "value": "1 heure exclue — vous étiez en mouvement."}}, "it": {"stringUnit": {"state": "translated", "value": "1 ora esclusa — eri in movimento."}}, "pl": {"stringUnit": {"state": "translated", "value": "Wykluczono 1 godzinę — wykryto ruch."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Foi excluída 1 hora — estiveste em movimento."}}, "ru": {"stringUnit": {"state": "translated", "value": "Исключён 1 час — вы двигались."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "因你当时在活动,已排除 1 小时。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "因你當時在活動,已排除 1 小時。"}} + } }, + "Autonomic load across your waking day": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Autonome Belastung im Verlauf deines wachen Tages"}}, "es": {"stringUnit": {"state": "translated", "value": "Carga autonómica durante las horas de vigilia"}}, "fr": {"stringUnit": {"state": "translated", "value": "Charge autonome au fil de votre journée éveillée"}}, "it": {"stringUnit": {"state": "translated", "value": "Carico autonomico durante le ore di veglia"}}, "pl": {"stringUnit": {"state": "translated", "value": "Obciążenie autonomiczne w ciągu dnia"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Carga autonómica ao longo do teu dia acordado"}}, "ru": {"stringUnit": {"state": "translated", "value": "Вегетативная нагрузка в течение дня бодрствования"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "清醒时段的自主神经负荷"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "清醒時段的自主神經負荷"}} + } }, + "The line is each waking hour's 0-3 proxy, scored against your personal daytime baseline (how your own days usually run). The bar below splits your day into calm, moderate and high stress time.": { "localizations": { + "de": {"stringUnit": {"state": "translated", "value": "Die Linie zeigt den 0–3-Näherungswert jeder Wachstunde, bewertet anhand deiner persönlichen Tages-Baseline (wie deine Tage normalerweise verlaufen). Der Balken darunter teilt deinen Tag in ruhige, mäßige und hohe Stresszeiten."}}, "es": {"stringUnit": {"state": "translated", "value": "La línea muestra el indicador de 0 a 3 de cada hora de vigilia, comparado con tu referencia diurna personal (cómo suelen transcurrir tus días). La barra inferior divide el día en periodos de estrés bajo, moderado y alto."}}, "fr": {"stringUnit": {"state": "translated", "value": "La ligne représente l'indice 0–3 de chaque heure éveillée, évalué par rapport à votre référence diurne personnelle (le déroulement habituel de vos journées). La barre ci-dessous répartit votre journée en périodes de stress faible, modéré et élevé."}}, "it": {"stringUnit": {"state": "translated", "value": "La linea mostra l'indicatore da 0 a 3 per ogni ora di veglia, valutato rispetto al tuo riferimento diurno personale (l'andamento abituale delle tue giornate). La barra sottostante divide la giornata in periodi di stress basso, moderato e alto."}}, "pl": {"stringUnit": {"state": "translated", "value": "Linia pokazuje wskaźnik 0–3 dla każdej godziny czuwania, oceniany względem Twojej osobistej dziennej linii bazowej (typowego przebiegu Twoich dni). Pasek poniżej dzieli dzień na okresy niskiego, umiarkowanego i wysokiego stresu."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "A linha mostra o indicador de 0–3 de cada hora de vigília, avaliado em relação à tua linha de base diurna pessoal (como os teus dias costumam decorrer). A barra abaixo divide o teu dia em períodos de stress baixo, moderado e elevado."}}, "ru": {"stringUnit": {"state": "translated", "value": "Линия показывает показатель 0–3 за каждый час бодрствования относительно вашей личной дневной нормы (обычного хода ваших дней). Полоса ниже делит день на периоды низкого, умеренного и высокого стресса."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "折线显示每个清醒小时的 0–3 指标,并以你的个人日间基线(你平时一天的状态)为基准评分。下方的条形将你的一天划分为低、中、高压力时段。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "折線顯示每個清醒小時的 0–3 指標,並以你的個人日間基線(你平時一天的狀態)為基準評分。下方的長條將你的一天劃分為低、中、高壓力時段。"}} + } }, "%lld working sets · compare when you repeat this program": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "%lld Arbeitssätze · vergleichbar, wenn du dieses Programm wiederholst"}}, "es": {"stringUnit": {"state": "translated", "value": "%lld series efectivas · compara al repetir este programa"}}, "fr": {"stringUnit": {"state": "translated", "value": "%lld séries de travail · à comparer quand tu refais ce programme"}}, "it": {"stringUnit": {"state": "translated", "value": "%lld serie di lavoro · confronta quando ripeti questo programma"}}, "pl": {"stringUnit": {"state": "translated", "value": "%lld serii roboczych · porównuj, gdy powtarzasz ten program"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "%lld séries de trabalho · compara quando repetires este programa"}}, "ru": {"stringUnit": {"state": "translated", "value": "%lld рабочих подходов · сравнивай при повторе этой программы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "%lld 个正式组 · 重复同一计划时才可比较"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "%lld 個正式組 · 重複同一計畫時才可比較"}} } }, @@ -161046,6 +161058,12 @@ "value": "La ligne représente l'indice 0–3 de chaque heure éveillée, noté par rapport à vos propres heures calmes d'aujourd'hui. La barre ci-dessous répartit votre journée en temps de stress calme, modéré et élevé." } }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La linea mostra l'indicatore da 0 a 3 per ogni ora di veglia, valutato rispetto alle tue ore calme di oggi. La barra sottostante divide la giornata in periodi di stress basso, moderato e alto." + } + }, "pt-PT": { "stringUnit": { "state": "translated", diff --git a/Strand/Screens/StressView.swift b/Strand/Screens/StressView.swift index 6b08f4f064..2bc30a228d 100644 --- a/Strand/Screens/StressView.swift +++ b/Strand/Screens/StressView.swift @@ -4,6 +4,15 @@ import StrandDesign import StrandAnalytics import WhoopStore +/// The shared explanation for an activity-masked gap on the Stress screen and hosted Today cards. +/// Whole-phrase singular/plural variants keep the sentence natural in every catalog locale. +func stressActivityMaskedHoursCaption(_ count: Int) -> String? { + guard count > 0 else { return nil } + return count == 1 + ? String(localized: "1 hour excluded — you were moving.") + : String(localized: "\(count) hours excluded — you were moving.") +} + // MARK: - Stress Monitor // // A clear, Whoop-style "Stress Monitor": one 0–3 number, a band (LOW/MEDIUM/HIGH), @@ -64,7 +73,7 @@ struct StressView: View { @State private var modelSignature: StressInputs? var body: some View { - ScreenScaffold(title: "Stress", subtitle: "Autonomic load from HRV and resting heart rate", + ScreenScaffold(title: "Stress", subtitle: "Autonomic load across your waking day", // PERF (scroll): lazy column — byte-identical layout (LazyVStack == eager VStack // alignment/spacing/header). The content is one inner eager VStack, so the staggered // section reveal is unchanged; this only defers building that stack until it scrolls in. @@ -315,6 +324,12 @@ struct StressView: View { .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .fixedSize(horizontal: false, vertical: true) + if let maskedCaption = stressActivityMaskedHoursCaption(day.activityMaskedHours) { + Text(maskedCaption) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } } } diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index d92d314947..d06cf12442 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -383,6 +383,7 @@ struct TodayView: View { // cheap heart-rate fingerprint, so a refresh that changed nothing costs one indexed COUNT and no // rows, and the iOS widget shares the same computation rather than scoring the day twice. @State private var hostedStressHours: [DaytimeStress.HourPoint] = [] + @State private var hostedStressActivityMaskedHours = 0 // TODAY's in-progress Effort (NOOP 0–100 axis), recomputed over the day's HR (local-midnight→now) // each load so the gauge tracks today as it accumulates rather than waiting on the heavy daily pass @@ -2562,6 +2563,12 @@ struct TodayView: View { .foregroundStyle(StrandPalette.textTertiary) .frame(maxWidth: .infinity, minHeight: 60, alignment: .center) } + if let maskedCaption = stressActivityMaskedHoursCaption(hostedStressActivityMaskedHours) { + Text(maskedCaption) + .font(StrandFont.footnote) + .foregroundStyle(StrandPalette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } } } case .asleepDuration: AsleepDurationCard(data: AsleepDurationData.build(days: repo.days)) @@ -4649,11 +4656,14 @@ struct TodayView: View { private func loadHostedStress() async { guard HostedCardPrefs.decodeEnabled(hostedCardsRaw).contains(.stressToday) else { hostedStressHours = [] + hostedStressActivityMaskedHours = 0 return } // `timeline`, not `hours`: the half-step display series, so the curve tracks the day rather // than stepping through it, matching the widget and the Android card. - hostedStressHours = await StressDayCurve.today(repo: repo)?.result.timeline ?? [] + let result = await StressDayCurve.today(repo: repo)?.result + hostedStressHours = result?.timeline ?? [] + hostedStressActivityMaskedHours = result?.activityMaskedHours ?? 0 } private func loadHostedSleepModel() async { diff --git a/Tools/i18n_extra_locale_baseline.txt b/Tools/i18n_extra_locale_baseline.txt index 7779c7b1f1..8909ee5cc5 100644 --- a/Tools/i18n_extra_locale_baseline.txt +++ b/Tools/i18n_extra_locale_baseline.txt @@ -14,7 +14,7 @@ NOOPWatch/Localizable.xcstrings:zh-Hant 0 NOOPWatchComplications/Localizable.xcstrings:it 25 NOOPWatchComplications/Localizable.xcstrings:zh-Hans 0 NOOPWatchComplications/Localizable.xcstrings:zh-Hant 0 -Strand/Resources/Localizable.xcstrings:it 179 +Strand/Resources/Localizable.xcstrings:it 178 Strand/Resources/Localizable.xcstrings:ru 1 Strand/Resources/Localizable.xcstrings:zh-Hans 0 Strand/Resources/Localizable.xcstrings:zh-Hant 79 diff --git a/android/app/src/main/java/com/noop/ui/StressScreen.kt b/android/app/src/main/java/com/noop/ui/StressScreen.kt index ad3d12961d..88e956ee4a 100644 --- a/android/app/src/main/java/com/noop/ui/StressScreen.kt +++ b/android/app/src/main/java/com/noop/ui/StressScreen.kt @@ -1,6 +1,7 @@ package com.noop.ui import com.noop.R +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.foundation.Canvas import androidx.compose.foundation.background @@ -126,6 +127,7 @@ fun StressScreen(vm: AppViewModel, onBreathe: () -> Unit = {}) { // banked HR + R-R via the SAME 0–3 proxy the daily score uses. Null until the read // completes; DaytimeStress.Result.EMPTY when the day has no usable intraday HR. var daytime by remember { mutableStateOf(null) } + var daytimeUsesPersonalBaseline by remember { mutableStateOf(false) } // ADDITIVE, on-demand advanced readouts, computed live from the SAME day's R-R the daytime // timeline already reads. These do NOT feed the 0..3 score or the timeline; they are two extra, // clearly-labelled HRV lenses surfaced in their own card. Each stays null when its engine's @@ -166,6 +168,7 @@ fun StressScreen(vm: AppViewModel, onBreathe: () -> Unit = {}) { val personal = NoopPrefs.stressPersonalBaseline(context) val core = runCatching { loadDaytimeCore(vm, personal) }.getOrNull() daytime = core?.daytime ?: DaytimeStress.Result.EMPTY + daytimeUsesPersonalBaseline = core?.usesPersonalBaseline == true val beats = core?.rr.orEmpty() val lenses = if (beats.isEmpty()) null else runCatching { withContext(Dispatchers.Default) { @@ -186,7 +189,7 @@ fun StressScreen(vm: AppViewModel, onBreathe: () -> Unit = {}) { LazyScreenScaffold( title = uiString(R.string.l10n_stress_screen_stress_bad33342), - subtitle = "Autonomic load from HRV and resting heart rate", + subtitle = uiString(R.string.stress_screen_subtitle), // LIQUID SKY BACKDROP (the pilot pattern — LiquidScreenSky.kt): the time-of-day liquid sky settles // into the theme canvas behind the header + hero vessel, full-bleed (full-width, up behind the // status bar via the scaffold's topBackground plumbing), and the cards float OVER it on the flat @@ -198,7 +201,9 @@ fun StressScreen(vm: AppViewModel, onBreathe: () -> Unit = {}) { fullBleedBackground = screenBackdropFullBleed(showDayCycleBackground, skyBehindCards), ) { when { - model != null -> StressContent(model, daytime, stressIndex, freqHrv, onBreathe) + model != null -> StressContent( + model, daytime, daytimeUsesPersonalBaseline, stressIndex, freqHrv, onBreathe, + ) !storedLoaded -> item { StressLoading() } else -> item { StressEmpty() } } @@ -215,6 +220,7 @@ fun StressScreen(vm: AppViewModel, onBreathe: () -> Unit = {}) { private data class DaytimeCore( val daytime: DaytimeStress.Result, val rr: List, + val usesPersonalBaseline: Boolean, ) /** @@ -245,7 +251,7 @@ private suspend fun loadDaytimeCore( val tzOffsetSeconds = zone.rules.getOffset(Instant.ofEpochSecond(nowSeconds)).totalSeconds.toLong() val hr = vm.repo.hrSamplesUnion(vm.activeStrapId, from, nowSeconds, limit = 200_000) if (hr.size < DaytimeStress.minHourHrSamples) { - return@withContext DaytimeCore(DaytimeStress.Result.EMPTY, emptyList()) + return@withContext DaytimeCore(DaytimeStress.Result.EMPTY, emptyList(), false) } val rr = vm.repo.rrIntervalsUnion(vm.activeStrapId, from, nowSeconds, limit = 200_000) // Wrist accelerometer for the motion gate: an ambulatory hour is EXERTION, not stress, so it is @@ -274,7 +280,11 @@ private suspend fun loadDaytimeCore( val daytime = DaytimeStress.analyze(hr, rr, gravity, tzOffsetSeconds, mode, includeTimeline = true) // ADDITIVE advanced readouts from the SAME `rr`. Each engine self-gates and returns null when // its requirement is not met, in which case its row is simply hidden in the UI. - DaytimeCore(daytime, rr) + DaytimeCore( + daytime, + rr, + usesPersonalBaseline = mode is DaytimeStress.ScoringMode.BaselineRelative, + ) } /** @@ -332,6 +342,7 @@ private suspend fun daytimeScoringMode( private fun androidx.compose.foundation.lazy.LazyListScope.StressContent( model: StressModel, daytime: DaytimeStress.Result?, + daytimeUsesPersonalBaseline: Boolean, stressIndex: StressIndex.Components?, freqHrv: HrvFreqDomain.Bands?, onBreathe: () -> Unit, @@ -363,7 +374,14 @@ private fun androidx.compose.foundation.lazy.LazyListScope.StressContent( // 3 · Today's intraday timeline — when in the day stress ran high, + a passive Breathe // suggestion when the recent hours stay elevated. if (daytime != null && daytime.scored.isNotEmpty()) { - item { StressDaytimeSection(daytime, onBreathe, modifier = Modifier.staggeredAppear(2)) } + item { + StressDaytimeSection( + daytime, + daytimeUsesPersonalBaseline, + onBreathe, + modifier = Modifier.staggeredAppear(2), + ) + } } // 4 · Trend over the chosen window. @@ -608,6 +626,7 @@ private const val staleTimelineSeconds: Long = 150L * 60L @Composable private fun StressDaytimeSection( day: DaytimeStress.Result, + usesPersonalBaseline: Boolean, onBreathe: () -> Unit, modifier: Modifier = Modifier, ) { @@ -664,12 +683,27 @@ private fun StressDaytimeSection( } Text( - uiString(R.string.l10n_stress_screen_the_line_traces_your_autonomic_load_804f4028) + - " against your own calm hours today (the same 0-3 proxy as the score " + - "above, read hour by hour). Hours without enough data are skipped.", + uiString( + if (usesPersonalBaseline) { + R.string.stress_timeline_caption_personal + } else { + R.string.stress_timeline_caption_day + } + ), style = NoopType.footnote, color = Palette.textTertiary, ) + if (day.activityMaskedHours > 0) { + Text( + pluralStringResource( + R.plurals.stress_hours_excluded_moving, + day.activityMaskedHours, + day.activityMaskedHours, + ), + style = NoopType.footnote, + color = Palette.textTertiary, + ) + } // Where the curve actually stops, said plainly, and only when it is far enough behind // the clock to look broken (#2144). The caption above gives the rule; a reader looking // at a line that ends at 2pm on an axis running to 4pm wants to know that THIS hour is @@ -716,7 +750,11 @@ private fun StressDaytimeSection( * the drawing differs, because Glance has to render to a Bitmap and Compose does not. */ @Composable -internal fun StressTodayCard(points: List, modifier: Modifier = Modifier) { +internal fun StressTodayCard( + points: List, + activityMaskedHours: Int, + modifier: Modifier = Modifier, +) { val stats = remember(points) { StressTrace.stats(points) } val ticks = remember(points) { StressTrace.timeTicks(points) } val textTertiary = Palette.textTertiary @@ -895,6 +933,17 @@ internal fun StressTodayCard(points: List, modifier: Modifier = Mod style = NoopType.footnote, color = textTertiary, ) + if (activityMaskedHours > 0) { + Text( + pluralStringResource( + R.plurals.stress_hours_excluded_moving, + activityMaskedHours, + activityMaskedHours, + ), + style = NoopType.footnote, + color = textTertiary, + ) + } } } } diff --git a/android/app/src/main/java/com/noop/ui/TodayScreen.kt b/android/app/src/main/java/com/noop/ui/TodayScreen.kt index bcba5b975d..cffcb5dc37 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScreen.kt @@ -3856,6 +3856,7 @@ private fun HostedCardsSection( // card and the widget can never show different curves. val needsStressCurve = cards.contains(HostedCard.STRESS_TODAY) var stressCurve by remember { mutableStateOf>(emptyList()) } + var stressActivityMaskedHours by remember { mutableStateOf(0) } // SEEDED from the curve already on disk, so an app update does not show "Calibrating" for a day it // has already scored. `stressCurve` starts empty on a cold process, and the card reads an empty // curve as an unscored day, which is honest for a genuinely unscored one and wrong the moment the @@ -3884,6 +3885,7 @@ private fun HostedCardsSection( LaunchedEffect(needsStressCurve, days, viewModel.activeStrapId, stressLifecycleOwner) { if (!needsStressCurve) { stressCurve = emptyList() + stressActivityMaskedHours = 0 return@LaunchedEffect } // Gated on STARTED, the same reason HealthScreen's live-HR tick is: a LaunchedEffect is tied to @@ -3900,7 +3902,10 @@ private fun HostedCardsSection( // the last curve matters more here than for a single pass: blanking the card on one bad // tick would be a visible flicker on a screen that is sitting open. StressWidgetProducer.todayCurve(viewModel.repo, viewModel.activeStrapId) - ?.let { stressCurve = it.points } + ?.let { + stressCurve = it.points + stressActivityMaskedHours = it.activityMaskedHours + } delay(StressWidgetProducer.RESCORE_INTERVAL_MS) } } @@ -3950,7 +3955,7 @@ private fun HostedCardsSection( .then(if (open != null) Modifier.clickable(onClick = open) else Modifier), ) { when (card) { - HostedCard.STRESS_TODAY -> StressTodayCard(stressCurve) + HostedCard.STRESS_TODAY -> StressTodayCard(stressCurve, stressActivityMaskedHours) // The Trends-origin trends. `resolveMetric` walks the `days` already in hand, so these // need no model build and no gate, unlike the sleep and stress cards above. HostedCard.TREND_HRV, HostedCard.TREND_RESTING_HR, HostedCard.TREND_EFFORT -> diff --git a/android/app/src/main/java/com/noop/widget/StressWidgetProducer.kt b/android/app/src/main/java/com/noop/widget/StressWidgetProducer.kt index 48a8274ba7..9dad4bac61 100644 --- a/android/app/src/main/java/com/noop/widget/StressWidgetProducer.kt +++ b/android/app/src/main/java/com/noop/widget/StressWidgetProducer.kt @@ -31,8 +31,12 @@ import kotlinx.coroutines.withContext */ internal object StressWidgetProducer { - /** Today's curve and the local day it belongs to. */ - data class Curve(val points: List, val epochDay: Long) + /** Today's curve, honest activity-masked hour count, and the local day they belong to. */ + data class Curve( + val points: List, + val epochDay: Long, + val activityMaskedHours: Int, + ) /** What the last computation saw and produced, swapped in as ONE value. * @@ -44,6 +48,7 @@ internal object StressWidgetProducer { val fingerprint: Pair, val day: Long, val points: List, + val activityMaskedHours: Int, ) @Volatile @@ -144,13 +149,15 @@ internal object StressWidgetProducer { // is part of the check because a fingerprint that happens to match across midnight would // otherwise serve yesterday's curve as today's. val memoHit = memo?.takeIf { it.day == day && it.fingerprint == fingerprint } - if (memoHit != null) return@runCatching Curve(memoHit.points, day) + if (memoHit != null) { + return@runCatching Curve(memoHit.points, day, memoHit.activityMaskedHours) + } val hr = repo.hrSamplesUnion(deviceId, from, nowSeconds, limit = 200_000) - val points = if (hr.size < DaytimeStress.minHourHrSamples) { + val result = if (hr.size < DaytimeStress.minHourHrSamples) { // Too little signal to score honestly. An EMPTY curve, not a null: this is a real // answer about today, and the widget should drop yesterday's line rather than keep it. - emptyList() + DaytimeStress.Result.EMPTY } else { val rr = repo.rrIntervalsUnion(deviceId, from, nowSeconds, limit = 200_000) // Wrist accelerometer for the motion gate, so an ambulatory hour reads as exertion @@ -164,16 +171,19 @@ internal object StressWidgetProducer { includeTimeline = true, // The half-step display series rather than the bare hours: same scored window, // same reference, read twice as often, so the curve tracks the day instead of - // stepping through it. Nothing here counts hours, so the overlap is free. - ).timeline.map { - // startTs is the wall-clock bucket start with the local shift already undone, so it - // is a true instant and formats correctly against the device's zone. - StressPoint(ts = it.startTs, level = it.level, moving = it.maskedForActivity) - } + // stepping through it. Coverage copy reads `activityMaskedHours` from the + // non-overlapping hourly result below; counting these display points would + // double-count the half-step windows. + ) + } + val points = result.timeline.map { + // startTs is the wall-clock bucket start with the local shift already undone, so it + // is a true instant and formats correctly against the device's zone. + StressPoint(ts = it.startTs, level = it.level, moving = it.maskedForActivity) } - memo = Memo(fingerprint, day, points) - Curve(points, day) + memo = Memo(fingerprint, day, points, result.activityMaskedHours) + Curve(points, day, result.activityMaskedHours) }.getOrNull() } diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 036ff981a5..258229e5d6 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -1982,6 +1982,13 @@ Noch keine Live-Herzfrequenz und kein synchronisierter Verlauf in dieser Sitzung. Kalibrierung Wird morgens fortgesetzt + Autonome Belastung im Verlauf deines wachen Tages + Die Linie zeigt den 0–3-Näherungswert jeder Wachstunde, bewertet anhand deiner eigenen ruhigen Stunden heute. Der Balken darunter teilt deinen Tag in ruhige, mäßige und hohe Stresszeiten. + Die Linie zeigt den 0–3-Näherungswert jeder Wachstunde, bewertet anhand deiner persönlichen Tages-Baseline (wie deine Tage normalerweise verlaufen). Der Balken darunter teilt deinen Tag in ruhige, mäßige und hohe Stresszeiten. + + %1$d Stunde ausgeschlossen — du warst in Bewegung. + %1$d Stunden ausgeschlossen — du warst in Bewegung. + Strap erforderlich Letzte Nacht · %1$s Letzter Schlaf · %1$s diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 98adbaff13..5d30209d7d 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -1950,6 +1950,13 @@ Aún no hay frecuencia cardíaca en vivo ni historial sincronizado en esta sesión. Calibrando Se reanuda por la mañana + Carga autonómica durante las horas de vigilia + La línea muestra el indicador de 0 a 3 de cada hora de vigilia, comparado con tus propias horas de calma de hoy. La barra inferior divide el día en periodos de estrés bajo, moderado y alto. + La línea muestra el indicador de 0 a 3 de cada hora de vigilia, comparado con tu referencia diurna personal (cómo suelen transcurrir tus días). La barra inferior divide el día en periodos de estrés bajo, moderado y alto. + + Se excluyó %1$d hora porque estabas en movimiento. + Se excluyeron %1$d horas porque estabas en movimiento. + Necesita la banda Anoche · %1$s Último sueño · %1$s diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index ba5df808e6..97dc6c9288 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -1968,6 +1968,13 @@ Aucune fréquence cardiaque en direct ni historique synchronisé durant cette session. Calibration en cours Reprend le matin + Charge autonome au fil de votre journée éveillée + La ligne représente l\'indice 0–3 de chaque heure éveillée, évalué par rapport à vos propres heures calmes d\'aujourd\'hui. La barre ci-dessous répartit votre journée en périodes de stress faible, modéré et élevé. + La ligne représente l\'indice 0–3 de chaque heure éveillée, évalué par rapport à votre référence diurne personnelle (le déroulement habituel de vos journées). La barre ci-dessous répartit votre journée en périodes de stress faible, modéré et élevé. + + %1$d heure exclue — vous étiez en mouvement. + %1$d heures exclues — vous étiez en mouvement. + Nécessite le bracelet La nuit dernière · %1$s Dernier sommeil · %1$s diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index 9a50e09f5c..1bf2a9fd0a 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -1939,6 +1939,15 @@ W tej sesji nie ma jeszcze tętna na żywo ani zsynchronizowanej historii. Kalibracja Wznowi się rano + Obciążenie autonomiczne w ciągu dnia + Linia pokazuje wskaźnik 0–3 dla każdej godziny czuwania, oceniany względem Twoich dzisiejszych spokojnych godzin. Pasek poniżej dzieli dzień na okresy niskiego, umiarkowanego i wysokiego stresu. + Linia pokazuje wskaźnik 0–3 dla każdej godziny czuwania, oceniany względem Twojej osobistej dziennej linii bazowej (typowego przebiegu Twoich dni). Pasek poniżej dzieli dzień na okresy niskiego, umiarkowanego i wysokiego stresu. + + Wykluczono %1$d godzinę — wykryto ruch. + Wykluczono %1$d godziny — wykryto ruch. + Wykluczono %1$d godzin — wykryto ruch. + Wykluczono %1$d godziny — wykryto ruch. + Potrzebuje paska Ostatnia noc · %1$s Ostatni sen · %1$s 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 0b85e0972c..f7670526f7 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -1943,6 +1943,13 @@ Ainda não há frequência cardíaca em direto nem histórico sincronizado nesta sessão. A calibrar Retoma de manhã + Carga autonómica ao longo do teu dia acordado + A linha mostra o indicador de 0–3 de cada hora de vigília, avaliado em relação às tuas próprias horas calmas de hoje. A barra abaixo divide o teu dia em períodos de stress baixo, moderado e elevado. + A linha mostra o indicador de 0–3 de cada hora de vigília, avaliado em relação à tua linha de base diurna pessoal (como os teus dias costumam decorrer). A barra abaixo divide o teu dia em períodos de stress baixo, moderado e elevado. + + Foi excluída %1$d hora — estiveste em movimento. + Foram excluídas %1$d horas — estiveste em movimento. + Precisa da pega Ontem à noite · %1$s Último sono · %1$s diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 3eb753c149..caddc2f9c4 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -1936,6 +1936,15 @@ Пока нет ни пульса в реальном времени, ни синхронизированной истории в этой сессии. Калибровка Возобновится утром + Вегетативная нагрузка в течение дня бодрствования + Линия показывает показатель 0–3 за каждый час бодрствования относительно ваших собственных спокойных часов сегодня. Полоса ниже делит день на периоды низкого, умеренного и высокого стресса. + Линия показывает показатель 0–3 за каждый час бодрствования относительно вашей личной дневной нормы (обычного хода ваших дней). Полоса ниже делит день на периоды низкого, умеренного и высокого стресса. + + Исключён %1$d час — вы двигались. + Исключено %1$d часа — вы двигались. + Исключено %1$d часов — вы двигались. + Исключено %1$d часа — вы двигались. + Нужен ремешок Прошлая ночь · %1$s Последний сон · %1$s diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 8370052ce2..afdee75e58 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -1872,6 +1872,12 @@ 本次连接尚无实时心率或已同步的历史记录。 校准中 早晨恢复 + 清醒时段的自主神经负荷 + 折线显示每个清醒小时的 0–3 指标,并以您今天自己的平静时段为基准评分。下方的条形将您的一天划分为低、中、高压力时段。 + 折线显示每个清醒小时的 0–3 指标,并以您的个人日间基线(您平时一天的状态)为基准评分。下方的条形将您的一天划分为低、中、高压力时段。 + + 因您当时在活动,已排除 %1$d 小时。 + 需要手环 昨晚 · %1$s 最近睡眠 · %1$s diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 6902a95728..a32a29a6cd 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2089,6 +2089,13 @@ No live heart rate or synced history yet this session. Calibrating Resumes in the morning + Autonomic load across your waking day + The line is each waking hour\'s 0–3 proxy, scored against your own calm hours today. The bar below splits your day into calm, moderate and high stress time. + The line is each waking hour\'s 0–3 proxy, scored against your personal daytime baseline (how your own days usually run). The bar below splits your day into calm, moderate and high stress time. + + %1$d hour excluded — you were moving. + %1$d hours excluded — you were moving. + Needs the strap Last night · %1$s Latest sleep · %1$s