From 93eecee5df8016f41bee9946f13e8f1ebc6a90c5 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Sat, 10 Oct 2026 01:27:34 +1300 Subject: [PATCH] fix(today): say what the heart-rate read returned, not why The Today HR card's empty state opened "Calibrating" and asserted "no heart rate banked yet today", then offered "your curve fills in as the strap offloads". None of that is established on that branch. The only thing the card knows is that the active-strap-plus-imports union came back with fewer than two 5-minute buckets for the window. It does not know whether a strap is calibrating, whether it has offloaded, or whether rows are banked under a source the union cannot reach, and naming an unchecked cause sends a reader looking in the wrong place. The same line also fired on a day holding exactly one bucket, where "no heart rate" is simply false. So the state now splits in two and describes the stored rows: 0 buckets No heart rate stored for today yet. The curve draws once the strap offloads, or once an import brings one in. 1 bucket Only one five-minute block of heart rate is stored for today. The curve needs two of them to draw a line. Both platforms, all nine locales. The split reads the DAY's bucket count rather than the windowed subset, because both lines speak about today: a narrow rolling window that happens to exclude the one stored block must not make the card claim the day holds nothing. Android's selection is extracted into a pure hrEmptyMessageRes so it can be pinned without a Compose host; Apple's is hoisted into hrEmptyTitle and hrEmptyDetail rather than nested as ternaries, since TodayView already sits near the type-check budget. HrEmptyMessageTest covers all six selections and carries source tripwires for the Apple twin, the retired resource key and the call site, none of which a JVM test can otherwise reach. Refs #863 --- Strand/Resources/Localizable.xcstrings | 232 ++++++++++++++++++ Strand/Screens/TodayView.swift | 38 ++- .../src/main/java/com/noop/ui/TodayScreen.kt | 55 ++++- .../app/src/main/res/values-de/strings.xml | 3 +- .../app/src/main/res/values-es/strings.xml | 3 +- .../app/src/main/res/values-fr/strings.xml | 3 +- .../app/src/main/res/values-it/strings.xml | 3 +- .../app/src/main/res/values-pl/strings.xml | 3 +- .../src/main/res/values-pt-rPT/strings.xml | 3 +- .../app/src/main/res/values-ru/strings.xml | 3 +- .../app/src/main/res/values-zh/strings.xml | 3 +- android/app/src/main/res/values/strings.xml | 3 +- .../java/com/noop/ui/HrEmptyMessageTest.kt | 113 +++++++++ 13 files changed, 439 insertions(+), 26 deletions(-) create mode 100644 android/app/src/test/java/com/noop/ui/HrEmptyMessageTest.kt diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 86b8f4b9d3..7f227851a7 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -571,6 +571,122 @@ } } }, + "No heart rate stored for today yet": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Für heute sind noch keine Herzfrequenzdaten gespeichert" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aún no hay frecuencia cardíaca guardada para hoy" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune fréquence cardiaque enregistrée pour aujourd’hui" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Per oggi non è ancora salvata alcuna frequenza cardiaca" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Na dziś nie zapisano jeszcze tętna" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Ainda sem frequência cardíaca guardada para hoje" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "За сегодня пульс ещё не сохранён" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "今天尚未保存心率数据" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "今天尚未儲存心率資料" + } + } + } + }, + "One five-minute block of heart rate today": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein Fünf-Minuten-Block der Herzfrequenz heute" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Un bloque de cinco minutos de frecuencia cardíaca hoy" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Un bloc de cinq minutes de fréquence cardiaque aujourd’hui" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Un blocco di cinque minuti di frequenza cardiaca oggi" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Jeden pięciominutowy blok tętna dzisiaj" + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "Um bloco de cinco minutos de frequência cardíaca hoje" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Один пятиминутный блок пульса за сегодня" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "今天有一个五分钟的心率数据块" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "今天有一個五分鐘的心率資料塊" + } + } + } + }, "Progress while NOOP pulls history from the strap.": { "localizations": { "de": { @@ -1939,6 +2055,122 @@ "Working sets": { "localizations": { "de": {"stringUnit": {"state": "translated", "value": "Arbeitssätze"}}, "es": {"stringUnit": {"state": "translated", "value": "Series efectivas"}}, "fr": {"stringUnit": {"state": "translated", "value": "Séries de travail"}}, "it": {"stringUnit": {"state": "translated", "value": "Serie di lavoro"}}, "pl": {"stringUnit": {"state": "translated", "value": "Serie robocze"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Séries de trabalho"}}, "ru": {"stringUnit": {"state": "translated", "value": "Рабочие подходы"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "正式组"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "正式組"}} } }, + "The curve draws once the strap offloads, or once an import brings one in.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Kurve wird gezeichnet, sobald der Strap überträgt oder ein Import welche mitbringt." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La curva se dibujará cuando la pulsera transfiera los datos o cuando una importación los traiga." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La courbe se trace dès que le bracelet transfère ses données ou qu’un import en apporte." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La curva viene tracciata quando la fascia scarica i dati o quando li porta un’importazione." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wykres pojawi się, gdy opaska prześle dane lub przyniesie je import." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A curva é desenhada quando a pulseira transferir os dados ou quando uma importação os trouxer." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Кривая появится, когда браслет выгрузит данные или их принесёт импорт." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "手环传输数据或导入带入数据后,曲线就会绘制。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "手環傳輸資料或匯入帶入資料後,曲線就會繪製。" + } + } + } + }, + "The curve needs two of them to draw a line.": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Für eine Linie braucht die Kurve zwei davon." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La curva necesita dos para trazar una línea." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Il en faut deux pour tracer une ligne." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Per tracciare una linea ne servono due." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Do narysowania linii potrzebne są dwa." + } + }, + "pt-PT": { + "stringUnit": { + "state": "translated", + "value": "A curva precisa de dois para traçar uma linha." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Чтобы провести линию, нужно два." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "绘制曲线需要两个。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "繪製曲線需要兩個。" + } + } + } + }, "While the strap is connected.": { "localizations": { "de": { diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index 4e91a53946..4935abfd9c 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -3570,6 +3570,27 @@ struct TodayView: View { // MARK: HEART RATE, today's continuous HR, off the strap's own ~1Hz history. + /// The Today HR card's empty-state heading, which states what the bucket read returned and nothing + /// else. It used to open "Calibrating" and assert that nothing had been banked today, neither of which + /// is established here: the gate is simply that the active-strap-plus-imports union came back with + /// under two buckets. Nothing on that branch knows whether a strap is calibrating, whether it has + /// offloaded, or whether rows sit under a source this union cannot see, and a card naming a cause it + /// has not checked sends a reader looking in the wrong place. One stored block is not "no heart rate" + /// either, so it gets its own line rather than being rounded down to zero. Android twin: the `when` in + /// `HeartRateTrendCard`'s empty branch. + private var hrEmptyTitle: String { + if selectedDayOffset != 0 { return String(localized: "No heart rate for this day") } + if hrPoints.count == 1 { return String(localized: "One five-minute block of heart rate today") } + return String(localized: "No heart rate stored for today yet") + } + + /// The line under `hrEmptyTitle`: what would put a curve here, without claiming why there is not one. + private var hrEmptyDetail: String { + if selectedDayOffset != 0 { return String(localized: "Step back to a day the strap was worn.") } + if hrPoints.count == 1 { return String(localized: "The curve needs two of them to draw a line.") } + return String(localized: "The curve draws once the strap offloads, or once an import brings one in.") + } + /// A full-width 24-hour heart-rate trend, plotted from 5-minute bucket means of the strap's /// `hrSample` history (offloaded even while the app was closed, so the day reads continuously). /// When there are fewer than two buckets it shows an explicit calibrating/empty card rather than @@ -3632,23 +3653,20 @@ struct TodayView: View { hrZoomHint } } else { - // #863: an empty / single-bucket day. A calibrating 4.0 banks HR slowly, so an empty curve early - // on isn't a fault , say so explicitly instead of leaving a blank where the chart was (which read - // as the graph freezing). We don't silently swap in another day's curve here; the honest empty - // state is the parity-matched fix. Mirrors the Android HeartRateTrendCard empty branch. + // #863: an empty / single-bucket day. An empty curve early on isn't a fault, so say what the + // read came back with instead of leaving a blank where the chart was (which read as the graph + // freezing). We don't silently swap in another day's curve here; the honest empty state is the + // parity-matched fix. The wording lives in `hrEmptyTitle` / `hrEmptyDetail`, which say what was + // found rather than why. Mirrors the Android HeartRateTrendCard empty branch. VStack(alignment: .leading, spacing: NoopMetrics.gap) { SectionHeader("Heart Rate", overline: "\(selectedDayOverline)") ChartCard( title: "Beats per minute", - subtitle: selectedDayOffset == 0 - ? String(localized: "Calibrating, no heart rate banked yet today") - : String(localized: "No heart rate for this day"), + subtitle: hrEmptyTitle, trailing: nil, tint: StrandPalette.metricRose ) { - Text(selectedDayOffset == 0 - ? String(localized: "Your curve fills in as the strap offloads its history.") - : String(localized: "Step back to a day the strap was worn.")) + Text(hrEmptyDetail) .font(StrandFont.footnote) .foregroundStyle(StrandPalette.textTertiary) .frame(maxWidth: .infinity, alignment: .center) 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 c6ba971de6..b07fa40a42 100644 --- a/android/app/src/main/java/com/noop/ui/TodayScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TodayScreen.kt @@ -1,5 +1,6 @@ package com.noop.ui +import androidx.annotation.StringRes import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -6455,6 +6456,35 @@ private fun HrWindowPills(selection: HrWindow, onSelect: (HrWindow) -> Unit) { ) } +/** + * Which line the Today HR card shows when it cannot draw a curve, chosen from what the read returned + * and from nothing else (#863). + * + * The card's gate is that the active-strap-plus-imports union came back with under two 5-minute + * buckets. It does NOT know whether a strap is calibrating, whether it has offloaded, or whether rows + * sit under a source the union cannot reach, which is why no branch here names a cause: the old single + * line opened "Calibrating" and asserted "no heart rate banked yet today", and a card naming an + * unchecked cause sends a reader looking in the wrong place. + * + * [dayBucketCount] is the DAY's count, never the windowed subset, because every branch below speaks + * about the day: a narrow rolling window that happens to exclude the one stored block must not make the + * card claim the day holds nothing. The window branch is the one exception and says so explicitly, and + * it only applies while the day itself has a drawable curve to go back to. + * + * Pure so the selection is pinned without a Compose host. Swift twin: `TodayView.hrEmptyTitle`. + */ +@StringRes +internal fun hrEmptyMessageRes( + isToday: Boolean, + windowIsWholeDay: Boolean, + dayBucketCount: Int, +): Int = when { + !isToday -> R.string.today_hr_empty_selected_day + !windowIsWholeDay && dayBucketCount >= 2 -> R.string.today_hr_empty_window + dayBucketCount == 1 -> R.string.today_hr_one_block + else -> R.string.today_hr_none_today +} + /** The width of the Today HR card's buckets. * * ONE literal, read by the load below and by the gap test in [OverviewHRChart]. They have to agree: @@ -6583,6 +6613,14 @@ private fun HeartRateTrendCard( // #985: the check reads the WINDOWED subset, and the pills stay visible in the empty state, so a // too-narrow rolling window (say 1h with no recent offload) is never a dead end — the user widens it // or steps back to Today, and the message says which window came up empty. + // + // The copy states what the READ returned and nothing else. It used to open "Calibrating" and assert + // "no heart rate banked yet today", neither of which is checked here: the gate is simply that the + // active-strap-plus-imports union came back with under two buckets. Nothing on this branch knows + // whether a strap is calibrating, whether it has offloaded, or whether rows are banked somewhere this + // union cannot see, and a card that names a cause it has not established sends a reader looking in the + // wrong place. One stored block is also not "no heart rate", so it gets its own line rather than being + // rounded down to zero. if (winBuckets.size < 2) { SectionHeader(uiString(R.string.today_section_heart_rate), overline = selectedLabel) NoopCard { @@ -6591,14 +6629,17 @@ private fun HeartRateTrendCard( if (selectedDay == today) { HrWindowPills(hrWindow) { hrWindowOrdinal = it.ordinal } } + val emptyRes = hrEmptyMessageRes( + isToday = selectedDay == today, + windowIsWholeDay = hrWindow == HrWindow.TODAY, + dayBucketCount = buckets.size, + ) Text( - when { - selectedDay != today -> - uiString(R.string.today_hr_empty_selected_day) - hrWindow != HrWindow.TODAY && buckets.size >= 2 -> - uiString(R.string.today_hr_empty_window, uiString(hrWindow.labelRes)) - else -> - uiString(R.string.today_hr_calibrating) + // Only the window line takes an argument; the rest are plain. + if (emptyRes == R.string.today_hr_empty_window) { + uiString(emptyRes, uiString(hrWindow.labelRes)) + } else { + uiString(emptyRes) }, style = NoopType.footnote, color = Palette.textTertiary, diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 544488b881..8399bc377c 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2257,7 +2257,8 @@ Alle Messwerte anzeigen (%1$d) Keine Herzfrequenzdaten für diesen Tag. Gehe zu einem Tag zurück, an dem du den Strap getragen hast. Keine Herzfrequenzdaten in den letzten %1$s. Wähle einen größeren Zeitraum oder „Heute“. - Kalibrierung läuft. Heute wurden noch keine Herzfrequenzdaten gespeichert. Die Kurve füllt sich, sobald der Strap Daten überträgt. + Für heute sind noch keine Herzfrequenzdaten gespeichert. Die Kurve wird gezeichnet, sobald der Strap überträgt oder ein Import welche mitbringt. + Für heute ist nur ein Fünf-Minuten-Block der Herzfrequenz gespeichert. Für eine Linie braucht die Kurve zwei davon. 5-Minuten-Durchschnitt | letzte %1$s Vergrößert · zum Verschieben ziehen Nicht verbunden diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 43b2fbbb59..20aed28131 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2244,7 +2244,8 @@ Mostrar todas las métricas (%1$d) No hay datos de frecuencia cardíaca para este día. Vuelve a un día en que llevaras la pulsera. No hay datos de frecuencia cardíaca en las últimas %1$s. Prueba un intervalo mayor o «Hoy». - Calibrando. Aún no se han guardado datos de frecuencia cardíaca hoy. La curva se completará cuando la pulsera transfiera los datos. + Aún no hay datos de frecuencia cardíaca guardados para hoy. La curva se dibujará cuando la pulsera los transfiera o cuando una importación los traiga. + Solo hay un bloque de cinco minutos de frecuencia cardíaca guardado para hoy. La curva necesita dos para trazar una línea. Media de 5 minutos | últimas %1$s Ampliado · arrastra para desplazarte Sin conexión diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index c9290f5722..bd1c4cc3b1 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2243,7 +2243,8 @@ Afficher toutes les mesures (%1$d) Aucune donnée de fréquence cardiaque pour ce jour. Revenez à un jour où vous portiez le bracelet. Aucune donnée de fréquence cardiaque au cours des dernières %1$s. Essayez une période plus large ou « Aujourd’hui ». - Étalonnage en cours. Aucune fréquence cardiaque enregistrée aujourd’hui. La courbe se complétera lorsque le bracelet transférera ses données. + Aucune fréquence cardiaque n’est encore enregistrée pour aujourd’hui. La courbe se trace dès que le bracelet transfère ses données ou qu’un import en apporte. + Un seul bloc de cinq minutes de fréquence cardiaque est enregistré pour aujourd’hui. Il en faut deux pour tracer une ligne. Moyenne sur 5 minutes | dernières %1$s Vue agrandie · faites glisser pour déplacer Non connecté diff --git a/android/app/src/main/res/values-it/strings.xml b/android/app/src/main/res/values-it/strings.xml index 8b358bba52..1a47561f99 100644 --- a/android/app/src/main/res/values-it/strings.xml +++ b/android/app/src/main/res/values-it/strings.xml @@ -2309,7 +2309,8 @@ Mostra tutte le metriche (%1$d) Nessuna frequenza cardiaca per questo giorno. Torna a un giorno in cui la fascia era indossata. Nessuna frequenza cardiaca nelle ultime %1$s. Prova una finestra più ampia o Oggi. - Calibrazione, ancora nessuna frequenza cardiaca salvata oggi. La curva si riempie man mano che la fascia scarica i dati. + Per oggi non è ancora salvata alcuna frequenza cardiaca. La curva viene tracciata quando la fascia scarica i dati o quando li porta un’importazione. + Per oggi è salvato un solo blocco di cinque minuti di frequenza cardiaca. Per tracciare una linea ne servono due. Media su 5 minuti | ultime %1$s Zoom attivo · trascina per spostarti Non connesso diff --git a/android/app/src/main/res/values-pl/strings.xml b/android/app/src/main/res/values-pl/strings.xml index 0985959e0e..b5e9c52de8 100644 --- a/android/app/src/main/res/values-pl/strings.xml +++ b/android/app/src/main/res/values-pl/strings.xml @@ -2242,7 +2242,8 @@ Pokaż wszystkie wskaźniki (%1$d) Brak danych tętna dla tego dnia. Wróć do dnia, w którym opaska była noszona. Brak danych tętna z ostatnich %1$s. Wybierz dłuższy okres lub „Dzisiaj”. - Trwa kalibracja. Dzisiaj nie zapisano jeszcze tętna. Wykres wypełni się po przesłaniu danych z opaski. + Na dziś nie zapisano jeszcze żadnych danych tętna. Wykres pojawi się, gdy opaska prześle dane lub przyniesie je import. + Na dziś zapisano tylko jeden pięciominutowy blok tętna. Do narysowania linii potrzebne są dwa. Średnia 5-minutowa | ostatnie %1$s Powiększono · przeciągnij, aby przesunąć Brak połączenia 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 91dca8a306..b64877aeb3 100644 --- a/android/app/src/main/res/values-pt-rPT/strings.xml +++ b/android/app/src/main/res/values-pt-rPT/strings.xml @@ -2236,7 +2236,8 @@ Mostrar todas as métricas (%1$d) Não há dados de frequência cardíaca para este dia. Recua até um dia em que tenhas usado a pulseira. Não há dados de frequência cardíaca nas últimas %1$s. Experimenta um período maior ou «Hoje». - A calibrar. Ainda não foram guardados dados de frequência cardíaca hoje. A curva será preenchida quando a pulseira transferir os dados. + Ainda não há dados de frequência cardíaca guardados para hoje. A curva é desenhada quando a pulseira transferir os dados ou quando uma importação os trouxer. + Para hoje está guardado apenas um bloco de cinco minutos de frequência cardíaca. A curva precisa de dois para traçar uma linha. Média de 5 minutos | últimas %1$s Vista ampliada · arrasta para deslocar Não ligado diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml index 3965dafee7..6fbec7268f 100644 --- a/android/app/src/main/res/values-ru/strings.xml +++ b/android/app/src/main/res/values-ru/strings.xml @@ -2199,7 +2199,8 @@ Показать все метрики (%1$d) Нет пульса за этот день. Вернитесь к дню, когда браслет был надет. Нет пульса за последние %1$s. Попробуйте более широкое окно или «Сегодня». - Калибровка, пульс за сегодня ещё не накоплен. Ваша кривая заполнится по мере выгрузки с браслета. + За сегодня пульс ещё не сохранён. Кривая появится, когда браслет выгрузит данные или их принесёт импорт. + За сегодня сохранён только один пятиминутный блок пульса. Чтобы провести линию, нужно два. 5-минутное среднее | последние %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 4818f600d0..75a9e8a04f 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -2164,7 +2164,8 @@ 显示全部指标(%1$d) 当天没有心率数据。请返回到佩戴手环的日期。 过去 %1$s 内没有心率数据。请尝试更长的时间范围或选择“今天”。 - 正在校准。今天尚未保存心率数据,手环传输数据后曲线会逐步显示。 + 今天尚未保存心率数据。手环传输数据或导入带入数据后,曲线就会绘制。 + 今天只保存了一个五分钟的心率数据块。绘制曲线需要两个。 5 分钟平均值 | 过去 %1$s 已放大 · 拖动平移 未连接 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 2f8ec2d0d0..5ae979343a 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2365,7 +2365,8 @@ Show all metrics (%1$d) No heart rate for this day. Step back to a day the strap was worn. No heart rate in the last %1$s. Try a wider window or Today. - Calibrating, no heart rate banked yet today. Your curve fills in as the strap offloads. + No heart rate stored for today yet. The curve draws once the strap offloads, or once an import brings one in. + Only one five-minute block of heart rate is stored for today. The curve needs two of them to draw a line. 5-minute average | last %1$s Zoomed in · drag to pan Not connected diff --git a/android/app/src/test/java/com/noop/ui/HrEmptyMessageTest.kt b/android/app/src/test/java/com/noop/ui/HrEmptyMessageTest.kt new file mode 100644 index 0000000000..fabd833f49 --- /dev/null +++ b/android/app/src/test/java/com/noop/ui/HrEmptyMessageTest.kt @@ -0,0 +1,113 @@ +package com.noop.ui + +import com.noop.R +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The Today HR card's empty state (#863): WHICH line it shows, and that none of them names a cause the + * card has not established. + * + * The old single line opened "Calibrating" and asserted "no heart rate banked yet today", neither of + * which the branch checks. All it knows is that the active-strap-plus-imports union returned under two + * 5-minute buckets, so the lines now describe the stored rows and stop there. The one-bucket case used + * to take that same "no heart rate" line, where it was plainly false. + */ +class HrEmptyMessageTest { + + @Test fun aPastDayAlwaysGetsTheStepBackLine() { + for (count in listOf(0, 1, 2, 500)) { + assertEquals( + "a past day's line never depends on the bucket count", + R.string.today_hr_empty_selected_day, + hrEmptyMessageRes(isToday = false, windowIsWholeDay = true, dayBucketCount = count), + ) + } + } + + @Test fun anEmptyDaySaysNothingIsStoredRatherThanWhy() { + assertEquals( + R.string.today_hr_none_today, + hrEmptyMessageRes(isToday = true, windowIsWholeDay = true, dayBucketCount = 0), + ) + } + + @Test fun oneStoredBlockIsNotReportedAsNoHeartRate() { + assertEquals( + R.string.today_hr_one_block, + hrEmptyMessageRes(isToday = true, windowIsWholeDay = true, dayBucketCount = 1), + ) + } + + @Test fun aNarrowWindowOverADrawableDaySaysSo() { + assertEquals( + R.string.today_hr_empty_window, + hrEmptyMessageRes(isToday = true, windowIsWholeDay = false, dayBucketCount = 2), + ) + } + + /** + * The regression the DAY-count rule exists for: a rolling 1h window that excludes the day's single + * stored block. Judged on the windowed subset this would read zero and claim the day holds nothing, + * which is the same over-claim in a new place. + */ + @Test fun aNarrowWindowCannotDowngradeTheDaysOneBlockToNothing() { + assertEquals( + R.string.today_hr_one_block, + hrEmptyMessageRes(isToday = true, windowIsWholeDay = false, dayBucketCount = 1), + ) + assertEquals( + R.string.today_hr_none_today, + hrEmptyMessageRes(isToday = true, windowIsWholeDay = false, dayBucketCount = 0), + ) + } + + /** + * Source tripwires. The Apple twin's selection sits in a SwiftUI computed property with no JVM + * reach, and the superseded copy lives in resource files no Kotlin test touches, so both are + * asserted from here, the suite that runs on every push. + */ + @Test fun neitherPlatformStillNamesAnUncheckedCause() { + val userDir = File(System.getProperty("user.dir") ?: ".") + val root = listOf(userDir, File(userDir, ".."), File(userDir, "../..")) + .firstOrNull { File(it, "Strand/Screens/TodayView.swift").isFile } + ?: error("could not locate the repo root from ${userDir.absolutePath}") + fun source(path: String) = File(root, path).readText() + + val apple = source("Strand/Screens/TodayView.swift") + assertTrue( + "the Apple card must choose its empty-state heading through hrEmptyTitle", + apple.contains("subtitle: hrEmptyTitle,"), + ) + assertTrue( + "the Apple heading must split the one-block case out of the empty case", + apple.contains("if hrPoints.count == 1 { return String(localized: " + + "\"One five-minute block of heart rate today\") }"), + ) + assertFalse( + "the Apple card must not claim the strap is calibrating", + apple.contains("Calibrating, no heart rate banked yet today"), + ) + + // The call site is what makes the day-count rule real: the function cannot see which list it + // was handed, so a switch to the windowed subset would pass every case above while putting the + // over-claim straight back on screen. + val todayScreen = source("android/app/src/main/java/com/noop/ui/TodayScreen.kt") + assertTrue( + "the card must judge the DAY's buckets, not the windowed subset", + todayScreen.contains("dayBucketCount = buckets.size,"), + ) + + val defaults = source("android/app/src/main/res/values/strings.xml") + assertFalse( + "the superseded Android line must not come back", + defaults.contains("today_hr_calibrating"), + ) + for (key in listOf("today_hr_none_today", "today_hr_one_block")) { + assertTrue("$key must exist in the default resources", defaults.contains("\"$key\"")) + } + } +}