Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 232 additions & 0 deletions Strand/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
38 changes: 28 additions & 10 deletions Strand/Screens/TodayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
55 changes: 48 additions & 7 deletions android/app/src/main/java/com/noop/ui/TodayScreen.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion android/app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2257,7 +2257,8 @@
<string name="today_show_all_metrics">Alle Messwerte anzeigen (%1$d)</string>
<string name="today_hr_empty_selected_day">Keine Herzfrequenzdaten für diesen Tag. Gehe zu einem Tag zurück, an dem du den Strap getragen hast.</string>
<string name="today_hr_empty_window">Keine Herzfrequenzdaten in den letzten %1$s. Wähle einen größeren Zeitraum oder „Heute“.</string>
<string name="today_hr_calibrating">Kalibrierung läuft. Heute wurden noch keine Herzfrequenzdaten gespeichert. Die Kurve füllt sich, sobald der Strap Daten überträgt.</string>
<string name="today_hr_none_today">Für heute sind noch keine Herzfrequenzdaten gespeichert. Die Kurve wird gezeichnet, sobald der Strap überträgt oder ein Import welche mitbringt.</string>
<string name="today_hr_one_block">Für heute ist nur ein Fünf-Minuten-Block der Herzfrequenz gespeichert. Für eine Linie braucht die Kurve zwei davon.</string>
<string name="today_hr_average_last_window">5-Minuten-Durchschnitt | letzte %1$s</string>
<string name="today_hr_zoomed_hint">Vergrößert · zum Verschieben ziehen</string>
<string name="today_not_connected">Nicht verbunden</string>
Expand Down
Loading
Loading