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\""))
+ }
+ }
+}