mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(insights): adopt the canonical per-metric detail template and terminology
Bring the web per-metric Insights detail pages to structural parity with the canonical metric template: a single top-to-bottom spine, every block self-suppressing when its data is absent. New spine (above the chart): header (title + target gear + Coach circle) → intro → primary tile → Letzte Messung → stat strip; then the chart, the range controls + period delta, the per-metric correlation card, and the Einschätzung assessment last. - Add the primary tile (headline value + 30-day average + an In-range bar shown only when the metric carries a per-metric in-target pct). Blood pressure keeps its richer target panel as its primary tile. - Add the Letzte Messung card (labeled heading + last-reading date), reading the last-seen timestamp the slim analytics slice already carries — no extra fetch. - Relocate the owning correlation onto its metric page: weight × weekday on Weight, mood × pulse on Pulse. The card reads the correlations already on the analytics payload and self-gates below the surfacing bar; it is now the canonical home for those relationships. - Move the headline value, 30-day average and in-target share out of the target reference panel so each figure appears in exactly one place; the panel stays the band reference (range, status, positional bar, consistency strip, source). Blood pressure keeps its stitched S/D 30-day average inline. - Order the Einschätzung last on every page. Terminology: adopt the canonical labels — rename the mood patterns surface to "Was auffällt", pluralise the deviation-count card to "Signale des Tages", and align the settings section labels to "Über diese App" and "Quellen-Priorität". New In-range / 30-day-average / Letzte Messung strings land in all six locales. Web charts are unchanged — Recharts and the existing chart tokens stay; the iOS monochrome chart doctrine is not applied.
This commit is contained in:
1 parent
6ad0a8a5cf
commit
d5eb887757
19 files changed
+564
-78
No files matched your search
+12
-4
@@ -1991,6 +1991,14 @@
|
||||
"inTargetShare": "{pct}% der erfassten Tage im Zielbereich",
|
||||
"adjustLink": "Zielbereich anpassen"
|
||||
},
|
||||
"primary": {
|
||||
"avg30Label": "30-Tage-Durchschnitt",
|
||||
"inRangeLabel": "Im Zielbereich",
|
||||
"inRangeAria": "{pct}% der erfassten Tage im Zielbereich"
|
||||
},
|
||||
"lastMeasurement": {
|
||||
"label": "Letzte Messung"
|
||||
},
|
||||
"explainer": {
|
||||
"trigger": "Was ist {title}?",
|
||||
"bloodPressureTitle": "Blutdruck",
|
||||
@@ -2574,7 +2582,7 @@
|
||||
"disclaimer": "Nur Zusammenhänge, keine Ursachen. Sprich mit deiner Ärztin oder deinem Arzt, bevor du etwas änderst."
|
||||
},
|
||||
"discovery": {
|
||||
"title": "Entdeckte Muster",
|
||||
"title": "Was auffällt",
|
||||
"description": "Tag-zu-Tag-Zusammenhänge rund um deine Stimmung, die auch nach Korrektur für viele gleichzeitige Tests bestehen.",
|
||||
"footer": "Herausgefiltert aus {tested} Tag-zu-Tag-Paaren, die über all deine Werte getestet wurden, nach der Korrektur für Fehlentdeckungen.",
|
||||
"empty": "Bisher hat kein Stimmungszusammenhang die statistische Hürde genommen. Logge weiter.",
|
||||
@@ -2749,7 +2757,7 @@
|
||||
"label": "Mehrere Vitalwerte außerhalb der Baseline",
|
||||
"summary": "{count} deiner Vitalwerte lagen heute außerhalb ihres persönlichen Bereichs.",
|
||||
"vitals": "Außerhalb des Bereichs: {list}",
|
||||
"cardTitle": "Signal des Tages",
|
||||
"cardTitle": "Signale des Tages",
|
||||
"allClear": "Alle deine Vitalwerte liegen heute in ihrem persönlichen Bereich.",
|
||||
"allClearMeta": "{count} deiner Vitalwerte gegen deinen eigenen typischen Bereich geprüft.",
|
||||
"watch": "Ein Vitalwert liegt heute außerhalb seines üblichen Bereichs.",
|
||||
@@ -3099,7 +3107,7 @@
|
||||
"description": "Pro Metrik dein eigener Zielbereich für gesunde Werte."
|
||||
},
|
||||
"sources": {
|
||||
"title": "Quellen",
|
||||
"title": "Quellen-Priorität",
|
||||
"description": "Lege fest, welche Quelle führt, wenn mehrere am selben Tag dieselbe Metrik liefern.",
|
||||
"cardTitle": "Quellen-Priorität pro Metrik",
|
||||
"help": "Wenn mehrere Quellen denselben Wert am selben Tag liefern (z. B. Withings + Apple Health beide 1.200 Schritte), wird die oberste Quelle als kanonisch verwendet. Andere Quellen bleiben gespeichert für die Audit-Spur.",
|
||||
@@ -3210,7 +3218,7 @@
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "Über",
|
||||
"title": "Über diese App",
|
||||
"description": "Version, Lizenz, Links."
|
||||
},
|
||||
"sharing": {
|
||||
|
||||
+12
-4
@@ -1991,6 +1991,14 @@
|
||||
"inTargetShare": "{pct}% of logged days within target",
|
||||
"adjustLink": "Adjust target range"
|
||||
},
|
||||
"primary": {
|
||||
"avg30Label": "30-day average",
|
||||
"inRangeLabel": "In range",
|
||||
"inRangeAria": "{pct}% of logged days within target"
|
||||
},
|
||||
"lastMeasurement": {
|
||||
"label": "Last measurement"
|
||||
},
|
||||
"explainer": {
|
||||
"trigger": "What is {title}?",
|
||||
"bloodPressureTitle": "Blood pressure",
|
||||
@@ -2574,7 +2582,7 @@
|
||||
"disclaimer": "Associations only, not causes. Talk to your doctor before changing anything based on these."
|
||||
},
|
||||
"discovery": {
|
||||
"title": "Discovered patterns",
|
||||
"title": "What stands out",
|
||||
"description": "Day-to-day relationships involving your mood that hold up after correcting for testing many pairs at once.",
|
||||
"footer": "Surfaced from {tested} day-to-day pairs tested across all your metrics, after the false-discovery correction.",
|
||||
"empty": "No mood relationships cleared the statistical bar yet. Keep logging.",
|
||||
@@ -2749,7 +2757,7 @@
|
||||
"label": "Several vitals off baseline",
|
||||
"summary": "{count} of your vitals sat outside their personal range today.",
|
||||
"vitals": "Outside range: {list}",
|
||||
"cardTitle": "Today's signal",
|
||||
"cardTitle": "Signals of the day",
|
||||
"allClear": "All your vitals are within their personal range today.",
|
||||
"allClearMeta": "Checked {count} of your vitals against your own typical range.",
|
||||
"watch": "One vital is outside its usual range today.",
|
||||
@@ -3099,7 +3107,7 @@
|
||||
"description": "Per metric: your own target range for healthy values."
|
||||
},
|
||||
"sources": {
|
||||
"title": "Sources",
|
||||
"title": "Source priority",
|
||||
"description": "Pick which ingest source wins when more than one records the same metric for the same day.",
|
||||
"cardTitle": "Source priority by metric",
|
||||
"help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.",
|
||||
@@ -3210,7 +3218,7 @@
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"title": "About this app",
|
||||
"description": "Version, license, links."
|
||||
},
|
||||
"sharing": {
|
||||
|
||||
+12
-4
@@ -1991,6 +1991,14 @@
|
||||
"inTargetShare": "{pct}% de los días registrados dentro del objetivo",
|
||||
"adjustLink": "Ajustar el rango objetivo"
|
||||
},
|
||||
"primary": {
|
||||
"avg30Label": "Promedio de 30 días",
|
||||
"inRangeLabel": "En el objetivo",
|
||||
"inRangeAria": "{pct}% de los días registrados dentro del objetivo"
|
||||
},
|
||||
"lastMeasurement": {
|
||||
"label": "Última medición"
|
||||
},
|
||||
"explainer": {
|
||||
"trigger": "¿Qué es {title}?",
|
||||
"bloodPressureTitle": "Presión arterial",
|
||||
@@ -2574,7 +2582,7 @@
|
||||
"disclaimer": "Solo asociaciones, no causas. Consulta a tu médico antes de cambiar nada por esto."
|
||||
},
|
||||
"discovery": {
|
||||
"title": "Patrones descubiertos",
|
||||
"title": "Lo que destaca",
|
||||
"description": "Relaciones de un día a otro con tu estado de ánimo que se mantienen tras corregir el haber probado muchos pares a la vez.",
|
||||
"footer": "Extraídas de {tested} pares de un día a otro probados en todas tus métricas, tras la corrección de falsos descubrimientos.",
|
||||
"empty": "Todavía ninguna relación de ánimo superó el umbral estadístico. Sigue registrando.",
|
||||
@@ -2749,7 +2757,7 @@
|
||||
"label": "Varias constantes fuera de la base",
|
||||
"summary": "Hoy, {count} de tus constantes quedaron fuera de su rango personal.",
|
||||
"vitals": "Fuera de rango: {list}",
|
||||
"cardTitle": "Señal de hoy",
|
||||
"cardTitle": "Señales del día",
|
||||
"allClear": "Hoy todas tus constantes están dentro de su rango personal.",
|
||||
"allClearMeta": "Se revisaron {count} de tus constantes frente a tu propio rango habitual.",
|
||||
"watch": "Hoy una constante está fuera de su rango habitual.",
|
||||
@@ -3099,7 +3107,7 @@
|
||||
"description": "Por métrica: tu propio rango objetivo de valores saludables."
|
||||
},
|
||||
"sources": {
|
||||
"title": "Fuentes",
|
||||
"title": "Prioridad de fuentes",
|
||||
"description": "Elige qué fuente prevalece cuando varias registran la misma métrica el mismo día.",
|
||||
"cardTitle": "Prioridad de fuentes por métrica",
|
||||
"help": "Cuando más de una fuente entrega el mismo valor el mismo día (p. ej. Withings y Apple Health informan ambos 1200 pasos), la fuente superior cuenta como la lectura canónica. Las demás fuentes se conservan para la traza de auditoría.",
|
||||
@@ -3210,7 +3218,7 @@
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "Acerca de",
|
||||
"title": "Acerca de esta app",
|
||||
"description": "Versión, licencia, enlaces."
|
||||
},
|
||||
"sharing": {
|
||||
|
||||
+12
-4
@@ -1991,6 +1991,14 @@
|
||||
"inTargetShare": "{pct}% des jours enregistrés dans la cible",
|
||||
"adjustLink": "Ajuster la plage cible"
|
||||
},
|
||||
"primary": {
|
||||
"avg30Label": "Moyenne sur 30 jours",
|
||||
"inRangeLabel": "Dans la cible",
|
||||
"inRangeAria": "{pct}% des jours enregistrés dans la cible"
|
||||
},
|
||||
"lastMeasurement": {
|
||||
"label": "Dernière mesure"
|
||||
},
|
||||
"explainer": {
|
||||
"trigger": "Qu'est-ce que {title} ?",
|
||||
"bloodPressureTitle": "Tension artérielle",
|
||||
@@ -2574,7 +2582,7 @@
|
||||
"disclaimer": "Des associations seulement, pas des causes. Parlez-en à votre médecin avant de changer quoi que ce soit."
|
||||
},
|
||||
"discovery": {
|
||||
"title": "Tendances détectées",
|
||||
"title": "Ce qui ressort",
|
||||
"description": "Des relations d'un jour à l'autre autour de votre humeur qui tiennent après correction des tests multiples.",
|
||||
"footer": "Issues de {tested} paires d'un jour à l'autre testées sur l'ensemble de vos mesures, après correction des fausses découvertes.",
|
||||
"empty": "Aucune relation d'humeur n'a encore franchi le seuil statistique. Continuez à enregistrer.",
|
||||
@@ -2749,7 +2757,7 @@
|
||||
"label": "Plusieurs constantes hors référence",
|
||||
"summary": "Aujourd’hui, {count} de vos constantes sont sorties de leur plage personnelle.",
|
||||
"vitals": "Hors plage : {list}",
|
||||
"cardTitle": "Signal du jour",
|
||||
"cardTitle": "Signaux du jour",
|
||||
"allClear": "Aujourd’hui, toutes vos constantes sont dans leur plage personnelle.",
|
||||
"allClearMeta": "{count} de vos constantes vérifiées par rapport à votre propre plage habituelle.",
|
||||
"watch": "Aujourd’hui, une constante est hors de sa plage habituelle.",
|
||||
@@ -3099,7 +3107,7 @@
|
||||
"description": "Par métrique : votre propre plage cible pour des valeurs saines."
|
||||
},
|
||||
"sources": {
|
||||
"title": "Sources",
|
||||
"title": "Priorité des sources",
|
||||
"description": "Choisissez quelle source prévaut quand plusieurs enregistrent la même métrique le même jour.",
|
||||
"cardTitle": "Priorité des sources par métrique",
|
||||
"help": "Lorsque plusieurs sources fournissent la même valeur le même jour (p. ex. Withings et Apple Health indiquent tous deux 1 200 pas), la source en tête compte comme la lecture canonique. Les autres sources restent enregistrées pour la piste d’audit.",
|
||||
@@ -3210,7 +3218,7 @@
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "À propos",
|
||||
"title": "À propos de cette app",
|
||||
"description": "Version, licence, liens."
|
||||
},
|
||||
"sharing": {
|
||||
|
||||
+12
-4
@@ -1991,6 +1991,14 @@
|
||||
"inTargetShare": "{pct}% dei giorni registrati nell'obiettivo",
|
||||
"adjustLink": "Modifica l'intervallo obiettivo"
|
||||
},
|
||||
"primary": {
|
||||
"avg30Label": "Media a 30 giorni",
|
||||
"inRangeLabel": "Nell'obiettivo",
|
||||
"inRangeAria": "{pct}% dei giorni registrati nell'obiettivo"
|
||||
},
|
||||
"lastMeasurement": {
|
||||
"label": "Ultima misurazione"
|
||||
},
|
||||
"explainer": {
|
||||
"trigger": "Che cos'è {title}?",
|
||||
"bloodPressureTitle": "Pressione arteriosa",
|
||||
@@ -2574,7 +2582,7 @@
|
||||
"disclaimer": "Solo associazioni, non cause. Parla con il tuo medico prima di cambiare qualcosa."
|
||||
},
|
||||
"discovery": {
|
||||
"title": "Schemi rilevati",
|
||||
"title": "Cosa risalta",
|
||||
"description": "Relazioni giorno per giorno legate al tuo umore che reggono dopo aver corretto per i test multipli.",
|
||||
"footer": "Estratte da {tested} coppie giorno per giorno testate su tutte le tue misure, dopo la correzione per le false scoperte.",
|
||||
"empty": "Nessuna relazione sull'umore ha ancora superato la soglia statistica. Continua a registrare.",
|
||||
@@ -2749,7 +2757,7 @@
|
||||
"label": "Più parametri fuori dalla base",
|
||||
"summary": "Oggi {count} dei tuoi parametri vitali sono usciti dal loro intervallo personale.",
|
||||
"vitals": "Fuori intervallo: {list}",
|
||||
"cardTitle": "Segnale di oggi",
|
||||
"cardTitle": "Segnali del giorno",
|
||||
"allClear": "Oggi tutti i tuoi parametri vitali sono nel loro intervallo personale.",
|
||||
"allClearMeta": "Controllati {count} dei tuoi parametri rispetto al tuo intervallo abituale.",
|
||||
"watch": "Oggi un parametro è fuori dal suo intervallo abituale.",
|
||||
@@ -3099,7 +3107,7 @@
|
||||
"description": "Per metrica: il tuo intervallo target per valori sani."
|
||||
},
|
||||
"sources": {
|
||||
"title": "Sorgenti",
|
||||
"title": "Priorità delle fonti",
|
||||
"description": "Scegli quale fonte prevale quando più di una registra la stessa metrica lo stesso giorno.",
|
||||
"cardTitle": "Priorità delle fonti per metrica",
|
||||
"help": "Quando più fonti forniscono lo stesso valore nello stesso giorno (ad es. Withings e Apple Health riportano entrambe 1.200 passi), la fonte in cima conta come lettura canonica. Le altre fonti restano memorizzate per la traccia di controllo.",
|
||||
@@ -3210,7 +3218,7 @@
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "Informazioni",
|
||||
"title": "Informazioni su questa app",
|
||||
"description": "Versione, licenza, link."
|
||||
},
|
||||
"sharing": {
|
||||
|
||||
+12
-4
@@ -1991,6 +1991,14 @@
|
||||
"inTargetShare": "{pct}% zarejestrowanych dni w zakresie celu",
|
||||
"adjustLink": "Dostosuj zakres docelowy"
|
||||
},
|
||||
"primary": {
|
||||
"avg30Label": "Średnia z 30 dni",
|
||||
"inRangeLabel": "W zakresie",
|
||||
"inRangeAria": "{pct}% zarejestrowanych dni w zakresie celu"
|
||||
},
|
||||
"lastMeasurement": {
|
||||
"label": "Ostatni pomiar"
|
||||
},
|
||||
"explainer": {
|
||||
"trigger": "Czym jest {title}?",
|
||||
"bloodPressureTitle": "Ciśnienie krwi",
|
||||
@@ -2574,7 +2582,7 @@
|
||||
"disclaimer": "Tylko powiązania, nie przyczyny. Skonsultuj się z lekarzem, zanim coś zmienisz."
|
||||
},
|
||||
"discovery": {
|
||||
"title": "Wykryte wzorce",
|
||||
"title": "Co się wyróżnia",
|
||||
"description": "Relacje dzień po dniu dotyczące Twojego nastroju, które utrzymują się po korekcie wielokrotnych testów.",
|
||||
"footer": "Wyłonione z {tested} par dzień po dniu testowanych na wszystkich Twoich wskaźnikach, po korekcie fałszywych odkryć.",
|
||||
"empty": "Żadna relacja nastroju nie przekroczyła jeszcze progu statystycznego. Notuj dalej.",
|
||||
@@ -2749,7 +2757,7 @@
|
||||
"label": "Kilka parametrów poza bazą",
|
||||
"summary": "Dziś {count} Twoich parametrów życiowych znalazło się poza osobistym zakresem.",
|
||||
"vitals": "Poza zakresem: {list}",
|
||||
"cardTitle": "Sygnał dnia",
|
||||
"cardTitle": "Sygnały dnia",
|
||||
"allClear": "Dziś wszystkie Twoje parametry życiowe mieszczą się w osobistym zakresie.",
|
||||
"allClearMeta": "Sprawdzono {count} Twoich parametrów względem Twojego typowego zakresu.",
|
||||
"watch": "Dziś jeden parametr jest poza swoim zwykłym zakresem.",
|
||||
@@ -3099,7 +3107,7 @@
|
||||
"description": "Dla każdej metryki: własny zakres docelowy zdrowych wartości."
|
||||
},
|
||||
"sources": {
|
||||
"title": "Źródła",
|
||||
"title": "Priorytet źródeł",
|
||||
"description": "Wybierz, które źródło wygrywa, gdy kilka rejestruje tę samą metrykę tego samego dnia.",
|
||||
"cardTitle": "Priorytet źródeł według metryki",
|
||||
"help": "Gdy więcej niż jedno źródło dostarcza tę samą wartość tego samego dnia (np. Withings i Apple Health raportują po 1200 kroków), źródło na górze liczy się jako odczyt kanoniczny. Pozostałe źródła pozostają zapisane na potrzeby ścieżki audytu.",
|
||||
@@ -3210,7 +3218,7 @@
|
||||
}
|
||||
},
|
||||
"about": {
|
||||
"title": "O aplikacji",
|
||||
"title": "O tej aplikacji",
|
||||
"description": "Wersja, licencja, linki."
|
||||
},
|
||||
"sharing": {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
|
||||
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
@@ -46,7 +47,10 @@ export default function InsightsBlutdruckPage() {
|
||||
const { data: status, isLoading: isStatusLoading } =
|
||||
useInsightStatus("blood-pressure");
|
||||
|
||||
const { isEmpty } = useInsightsAnalytics("BLOOD_PRESSURE_SYS");
|
||||
const { data: analytics, isEmpty } =
|
||||
useInsightsAnalytics("BLOOD_PRESSURE_SYS");
|
||||
const bpLastSeenAt =
|
||||
analytics?.lastSeenByType?.BLOOD_PRESSURE_SYS?.lastSeenAt ?? null;
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
@@ -103,6 +107,15 @@ export default function InsightsBlutdruckPage() {
|
||||
description={t("insights.subPage.blutdruckDescription")}
|
||||
explainerMetric="bloodPressure"
|
||||
coachLaunch
|
||||
primary={
|
||||
<>
|
||||
{/* Blood pressure's primary tile IS its richer target panel
|
||||
(ESH classification + sys/dia band + 30-day S/D average),
|
||||
promoted above the chart per the canonical spine. */}
|
||||
<MetricTargetSummary slug="blood-pressure" />
|
||||
<MetricLastMeasurementCard lastSeenAt={bpLastSeenAt} />
|
||||
</>
|
||||
}
|
||||
diversityNudge={
|
||||
<MeasurementDiversityNudge
|
||||
measurementType="BLOOD_PRESSURE_SYS"
|
||||
@@ -132,8 +145,8 @@ export default function InsightsBlutdruckPage() {
|
||||
{/* v1.12.0 — range pills + period-over-period delta below the chart. */}
|
||||
<MetricRangeControls measurementType="BLOOD_PRESSURE_SYS" enabled={!isEmpty} />
|
||||
|
||||
<MetricTargetSummary slug="blood-pressure" />
|
||||
|
||||
{/* v1.12.0 — Einschätzung is the last block on the canonical
|
||||
metric-detail spine. */}
|
||||
<InsightStatusCard
|
||||
title={t("insights.assessmentTitle")}
|
||||
icon={<HeartPulse className="h-5 w-5" />}
|
||||
|
||||
@@ -13,6 +13,9 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
|
||||
import { MetricPrimaryTile } from "@/components/insights/metric-primary-tile";
|
||||
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
|
||||
import { MetricCorrelationCard } from "@/components/insights/metric-correlation-card";
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
@@ -52,6 +55,8 @@ export default function InsightsPulsPage() {
|
||||
// the same key).
|
||||
const { data: analytics, isEmpty } = useInsightsAnalytics("PULSE");
|
||||
const vo2Summary = analytics?.summaries?.VO2_MAX ?? null;
|
||||
const pulseSummary = analytics?.summaries?.PULSE ?? null;
|
||||
const pulseLastSeenAt = analytics?.lastSeenByType?.PULSE?.lastSeenAt ?? null;
|
||||
|
||||
// v1.4.27 F17 — gate the sub-page on at least one pulse observation.
|
||||
// Brand-new accounts (no manual logs, no Apple-Health upload yet)
|
||||
@@ -116,12 +121,13 @@ export default function InsightsPulsPage() {
|
||||
title={t("insights.pulseSectionTitle")}
|
||||
description={t("insights.subPage.pulsDescription")}
|
||||
explainerMetric="pulse"
|
||||
statStrip={
|
||||
<MetricStatStrip
|
||||
summary={analytics?.summaries?.PULSE ?? null}
|
||||
unit="bpm"
|
||||
/>
|
||||
primary={
|
||||
<>
|
||||
<MetricPrimaryTile summary={pulseSummary} unit="bpm" slug="pulse" />
|
||||
<MetricLastMeasurementCard lastSeenAt={pulseLastSeenAt} />
|
||||
</>
|
||||
}
|
||||
statStrip={<MetricStatStrip summary={pulseSummary} unit="bpm" />}
|
||||
diversityNudge={
|
||||
<MeasurementDiversityNudge
|
||||
measurementType="PULSE"
|
||||
@@ -147,16 +153,6 @@ export default function InsightsPulsPage() {
|
||||
|
||||
<MetricTargetSummary slug="pulse" />
|
||||
|
||||
<InsightStatusCard
|
||||
title={t("insights.assessmentTitle")}
|
||||
icon={<Heart className="h-5 w-5" />}
|
||||
text={status?.text ?? null}
|
||||
hasProvider={status?.hasProvider ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
preparing={status?.preparing ?? false}
|
||||
/>
|
||||
|
||||
{/* v1.4.25 W16a — VO2 max sits on the cardio sub-page because it
|
||||
is a cardio-fitness metric (Apple's Health app surfaces it
|
||||
under "Heart"). The chart-row stays mounted even at zero
|
||||
@@ -168,6 +164,22 @@ export default function InsightsPulsPage() {
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
|
||||
{/* v1.12.0 — Pulse owns the mood × pulse correlation (relocated off
|
||||
the overview onto its metric page). */}
|
||||
<MetricCorrelationCard slug="pulse" />
|
||||
|
||||
{/* v1.12.0 — Einschätzung is the last block on the canonical
|
||||
metric-detail spine. */}
|
||||
<InsightStatusCard
|
||||
title={t("insights.assessmentTitle")}
|
||||
icon={<Heart className="h-5 w-5" />}
|
||||
text={status?.text ?? null}
|
||||
hasProvider={status?.hasProvider ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
preparing={status?.preparing ?? false}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricStatusCard } from "@/components/insights/metric-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
|
||||
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SleepOverview } from "@/components/insights/sleep-overview";
|
||||
@@ -35,7 +36,10 @@ export default function InsightsSchlafPage() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { isEmpty } = useInsightsAnalytics("SLEEP_DURATION");
|
||||
const { data: analytics, isEmpty } =
|
||||
useInsightsAnalytics("SLEEP_DURATION");
|
||||
const sleepLastSeenAt =
|
||||
analytics?.lastSeenByType?.SLEEP_DURATION?.lastSeenAt ?? null;
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
@@ -67,6 +71,7 @@ export default function InsightsSchlafPage() {
|
||||
description={t("insights.sleep.description")}
|
||||
explainerMetric="sleep"
|
||||
coachLaunch
|
||||
primary={<MetricLastMeasurementCard lastSeenAt={sleepLastSeenAt} />}
|
||||
diversityNudge={
|
||||
<MeasurementDiversityNudge
|
||||
measurementType="SLEEP_DURATION"
|
||||
|
||||
@@ -13,6 +13,9 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
|
||||
import { MetricPrimaryTile } from "@/components/insights/metric-primary-tile";
|
||||
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
|
||||
import { MetricCorrelationCard } from "@/components/insights/metric-correlation-card";
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
@@ -42,6 +45,8 @@ export default function InsightsGewichtPage() {
|
||||
|
||||
const { data: analytics, isEmpty } = useInsightsAnalytics("WEIGHT");
|
||||
const weightSummary = analytics?.summaries?.WEIGHT ?? null;
|
||||
const weightLastSeenAt =
|
||||
analytics?.lastSeenByType?.WEIGHT?.lastSeenAt ?? null;
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
@@ -79,6 +84,12 @@ export default function InsightsGewichtPage() {
|
||||
title={t("insights.weightSectionTitle")}
|
||||
description={t("insights.subPage.gewichtDescription")}
|
||||
explainerMetric="weight"
|
||||
primary={
|
||||
<>
|
||||
<MetricPrimaryTile summary={weightSummary} unit="kg" slug="weight" />
|
||||
<MetricLastMeasurementCard lastSeenAt={weightLastSeenAt} />
|
||||
</>
|
||||
}
|
||||
statStrip={<MetricStatStrip summary={weightSummary} unit="kg" />}
|
||||
diversityNudge={
|
||||
<MeasurementDiversityNudge
|
||||
@@ -113,6 +124,10 @@ export default function InsightsGewichtPage() {
|
||||
compact
|
||||
/>
|
||||
|
||||
{/* v1.12.0 — Weight owns the weight × weekday correlation (relocated
|
||||
off the overview onto its metric page). */}
|
||||
<MetricCorrelationCard slug="weight" />
|
||||
|
||||
<InsightStatusCard
|
||||
title={t("insights.assessmentTitle")}
|
||||
icon={<Scale className="h-5 w-5" />}
|
||||
|
||||
@@ -154,12 +154,15 @@ const GLUCOSE_DATA: TargetsFixture = {
|
||||
};
|
||||
|
||||
describe("<MetricTargetSummary>", () => {
|
||||
it("renders the numeric range + in-target share for a simple metric", () => {
|
||||
it("renders the numeric range for a simple metric", () => {
|
||||
const html = renderWith("weight", WEIGHT_DATA);
|
||||
// Trailing `.0` trimmed, single-decimal kept.
|
||||
expect(html).toContain("Target: 60.1–80.9 kg");
|
||||
// round(18/24*100) = 75
|
||||
expect(html).toContain("75% of logged days within target");
|
||||
// v1.12.0 — the in-target share moved up to `<MetricPrimaryTile>`,
|
||||
// the canonical home for the headline + 30-day average + in-range
|
||||
// bar. The band-reference panel no longer repeats it for non-BP
|
||||
// metrics.
|
||||
expect(html).not.toContain("of logged days within target");
|
||||
});
|
||||
|
||||
it("renders the verbal status pill + the guideline source", () => {
|
||||
@@ -175,21 +178,26 @@ describe("<MetricTargetSummary>", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the range bar + the 30-day average + the consistency strip", () => {
|
||||
it("renders the range bar + the consistency strip", () => {
|
||||
const html = renderWith("weight", WEIGHT_DATA);
|
||||
expect(html).toContain('data-slot="target-range-bar"');
|
||||
expect(html).toContain('data-slot="consistency-strip"');
|
||||
expect(html).toContain("30-day average: 71.4 kg");
|
||||
// v1.12.0 — the 30-day average moved up to `<MetricPrimaryTile>` for
|
||||
// every metric except blood pressure (whose richer panel IS its
|
||||
// primary tile and keeps the stitched S/D average inline).
|
||||
expect(html).not.toContain("30-day average: 71.4 kg");
|
||||
});
|
||||
|
||||
it("stitches the systolic + diastolic bands for blood pressure", () => {
|
||||
const html = renderWith("blood-pressure", BP_DATA);
|
||||
expect(html).toContain("Target: 120–129 / 70–79 mmHg");
|
||||
expect(html).toContain("50% of logged days within target");
|
||||
// v1.12.0 — the in-target share moved up to the primary tile; the BP
|
||||
// panel keeps only the stitched 30-day average inline.
|
||||
expect(html).not.toContain("of logged days within target");
|
||||
// The diastolic range bar renders alongside the systolic one.
|
||||
const barCount = (html.match(/data-slot="target-range-bar"/g) ?? []).length;
|
||||
expect(barCount).toBe(2);
|
||||
// 30-day average stitches the diastolic pair.
|
||||
// 30-day average stitches the diastolic pair — kept on the BP panel.
|
||||
expect(html).toContain("30-day average: 122/76 mmHg");
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import { TrajectoryForecastCard } from "@/components/insights/derived/trajectory
|
||||
import { isTrajectoryType } from "@/lib/insights/derived/registry";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
|
||||
import { MetricPrimaryTile } from "@/components/insights/metric-primary-tile";
|
||||
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
|
||||
@@ -177,6 +179,9 @@ export function HealthKitMetricPage({
|
||||
}
|
||||
: rawSummary;
|
||||
|
||||
const lastSeenAt =
|
||||
analytics?.lastSeenByType?.[measurementType]?.lastSeenAt ?? null;
|
||||
|
||||
const title = t(`${i18nPrefix}.title`);
|
||||
const description = t(`${i18nPrefix}.description`);
|
||||
|
||||
@@ -211,6 +216,16 @@ export function HealthKitMetricPage({
|
||||
title={title}
|
||||
description={description}
|
||||
explainerMetric={explainerMetric}
|
||||
primary={
|
||||
<>
|
||||
<MetricPrimaryTile
|
||||
summary={summary}
|
||||
unit={yAxisUnit ?? unit}
|
||||
slug={targetSummarySlug}
|
||||
/>
|
||||
<MetricLastMeasurementCard lastSeenAt={lastSeenAt} />
|
||||
</>
|
||||
}
|
||||
statStrip={<MetricStatStrip summary={summary} unit={yAxisUnit ?? unit} />}
|
||||
diversityNudge={
|
||||
<MeasurementDiversityNudge
|
||||
@@ -244,13 +259,6 @@ export function HealthKitMetricPage({
|
||||
{targetSummarySlug ? (
|
||||
<MetricTargetSummary slug={targetSummarySlug} />
|
||||
) : null}
|
||||
{statusMetric ? (
|
||||
<MetricStatusCard
|
||||
metric={statusMetric}
|
||||
icon={<Sparkles className="h-5 w-5" />}
|
||||
enabled={!isEmpty}
|
||||
/>
|
||||
) : null}
|
||||
{forecast && isTrajectoryType(measurementType) ? (
|
||||
<TrajectoryForecastCard
|
||||
type={measurementType}
|
||||
@@ -261,6 +269,15 @@ export function HealthKitMetricPage({
|
||||
compact
|
||||
/>
|
||||
) : null}
|
||||
{/* v1.12.0 — Einschätzung is the last block on the canonical
|
||||
metric-detail spine. */}
|
||||
{statusMetric ? (
|
||||
<MetricStatusCard
|
||||
metric={statusMetric}
|
||||
icon={<Sparkles className="h-5 w-5" />}
|
||||
enabled={!isEmpty}
|
||||
/>
|
||||
) : null}
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type {
|
||||
CorrelationKind,
|
||||
CorrelationResult,
|
||||
} from "@/lib/insights/correlations";
|
||||
import { CorrelationCard } from "@/components/insights/correlation-card";
|
||||
|
||||
/**
|
||||
* v1.12.0 — per-metric correlation card.
|
||||
*
|
||||
* The canonical home for the one cross-metric relationship a metric
|
||||
* owns (the iOS canonical template relocates correlations off the
|
||||
* overview onto the owning metric page):
|
||||
*
|
||||
* • Weight page → weight × weekday
|
||||
* • Pulse page → mood × pulse
|
||||
*
|
||||
* It reads the already-computed `correlations` block off the thick
|
||||
* `/api/analytics` slice — the same payload the Insights overview warms.
|
||||
* On the common path (user lands on the overview first) this is a free
|
||||
* cache read; on direct metric-page entry it pays one thick fetch, the
|
||||
* same trade-off `<MetricTargetSummary>` already makes for the targets
|
||||
* payload.
|
||||
*
|
||||
* Self-suppressing: renders nothing while the payload is in-flight, when
|
||||
* the correlations feature is off, or when the owned hypothesis is below
|
||||
* the statistical surfacing bar (`status !== "ok"`). The single
|
||||
* `<CorrelationCard>` carries its own scatter + interpretation + source.
|
||||
*/
|
||||
|
||||
interface AnalyticsWithCorrelations {
|
||||
correlations?: Partial<Record<string, CorrelationResult>>;
|
||||
}
|
||||
|
||||
/** Insights slug → the `correlations` payload key it owns. */
|
||||
const SLUG_TO_CORRELATION: Record<string, string> = {
|
||||
weight: "weightWeekday",
|
||||
pulse: "moodPulse",
|
||||
};
|
||||
|
||||
/** The `kind` the owned result must carry — guards a payload-key drift. */
|
||||
const SLUG_TO_KIND: Record<string, CorrelationKind> = {
|
||||
weight: "weight-weekday",
|
||||
pulse: "mood-pulse",
|
||||
};
|
||||
|
||||
interface MetricCorrelationCardProps {
|
||||
/** Insights category slug, e.g. `"weight"`. */
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export function MetricCorrelationCard({ slug }: MetricCorrelationCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const flags = useFeatureFlags();
|
||||
const key = SLUG_TO_CORRELATION[slug];
|
||||
|
||||
// Read the thick slice; correlations live only on the default payload.
|
||||
// The query is gated by the hook on `isAuthenticated`, and disabled
|
||||
// entirely when this slug owns no correlation so a target-less metric
|
||||
// never fires the thick fetch.
|
||||
const { data } = useAnalyticsQuery({ enabled: key != null ? undefined : false });
|
||||
const analytics = data as AnalyticsWithCorrelations | undefined;
|
||||
|
||||
if (!flags.correlations || !key) return null;
|
||||
|
||||
const result = analytics?.correlations?.[key];
|
||||
if (!result || result.status !== "ok") return null;
|
||||
// Guard against a payload-shape drift: only paint when the kind the
|
||||
// server returned matches the kind this slug claims to own.
|
||||
if (result.kind !== SLUG_TO_KIND[slug]) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="metric-correlation"
|
||||
data-correlation-slug={slug}
|
||||
aria-label={t("insights.correlationRow.title")}
|
||||
className="space-y-2"
|
||||
>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("insights.correlationRow.title")}
|
||||
</h2>
|
||||
<CorrelationCard result={result} />
|
||||
<p
|
||||
data-slot="metric-correlation-disclaimer"
|
||||
className="text-muted-foreground text-xs italic"
|
||||
>
|
||||
{t("insights.correlationRow.disclaimer")}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { Clock } from "lucide-react";
|
||||
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { formatRelativeTime } from "@/lib/i18n/relative-time";
|
||||
|
||||
/**
|
||||
* v1.12.0 — canonical "Letzte Messung" card.
|
||||
*
|
||||
* The third block on the per-metric detail page (after the primary
|
||||
* tile), mirroring the iOS `InsightsLastMeasurementCard`. It captions
|
||||
* WHEN the most recent reading landed — the date plus a "vor X Tagen"
|
||||
* recency line.
|
||||
*
|
||||
* It deliberately does NOT repeat the latest value: the headline value
|
||||
* lives in `<MetricPrimaryTile>` only (the no-duplicate-info rule). This
|
||||
* card owns the timing dimension; the primary tile owns the value.
|
||||
*
|
||||
* Self-suppressing: renders nothing when no `lastSeenAt` is available
|
||||
* (a brand-new metric, or a metric whose freshness map has no entry),
|
||||
* so a sparse page never carries an empty timing card.
|
||||
*/
|
||||
|
||||
interface MetricLastMeasurementCardProps {
|
||||
/**
|
||||
* ISO timestamp of the latest reading for this metric, read from the
|
||||
* analytics `lastSeenByType[type]?.lastSeenAt` slot. Null / undefined
|
||||
* suppresses the card.
|
||||
*/
|
||||
lastSeenAt: string | null | undefined;
|
||||
}
|
||||
|
||||
export function MetricLastMeasurementCard({
|
||||
lastSeenAt,
|
||||
}: MetricLastMeasurementCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
if (!lastSeenAt) return null;
|
||||
const parsed = new Date(lastSeenAt);
|
||||
if (Number.isNaN(parsed.getTime())) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="metric-last-measurement"
|
||||
className="bg-card border-border flex items-center gap-3 rounded-xl border px-4 py-3"
|
||||
>
|
||||
<div className="bg-muted text-muted-foreground flex size-9 shrink-0 items-center justify-center rounded-full">
|
||||
<Clock className="size-4" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="text-muted-foreground text-[0.6875rem] font-medium tracking-[0.06em] uppercase">
|
||||
{t("insights.subPage.lastMeasurement.label")}
|
||||
</p>
|
||||
<p className="text-sm font-medium">
|
||||
{fmt.dateWithWeekday(parsed)}
|
||||
<span className="text-muted-foreground ml-2 font-normal">
|
||||
{formatRelativeTime(lastSeenAt, t)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.12.0 — canonical metric primary tile.
|
||||
*
|
||||
* The first data block on every per-metric detail page, mirroring the
|
||||
* iOS `InsightsPrimaryTile`:
|
||||
*
|
||||
* • the headline value — the latest reading (the single place on the
|
||||
* page the headline number appears; the stat strip below carries
|
||||
* min / max / median / mean, not the latest),
|
||||
* • the 30-Tage-Durchschnitt (`summary.avg30`) as a secondary line,
|
||||
* • an "Im Zielbereich" bar — the share of logged days in target over
|
||||
* the last 30, shown ONLY when the metric carries a per-metric pct
|
||||
* (a registered target band feeds `/api/insights/targets`).
|
||||
*
|
||||
* Self-suppressing: renders nothing without a summary or with a
|
||||
* zero-count series (the page empty-state owns that surface). The
|
||||
* in-target bar omits itself when the slug has no numeric target or the
|
||||
* route flagged insufficient data, so a target-less metric (HRV, SpO₂,
|
||||
* temperature, …) reads as a clean headline + 30-day average.
|
||||
*
|
||||
* Blood pressure does NOT use this tile — its richer BP target panel is
|
||||
* its primary tile (the page omits this component and leads with
|
||||
* `<MetricTargetSummary slug="blood-pressure" />`).
|
||||
*/
|
||||
|
||||
interface TargetRange {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
interface TargetItem {
|
||||
type: string;
|
||||
daysInRange30d: number;
|
||||
daysLogged30d: number;
|
||||
insufficientData: boolean;
|
||||
range: TargetRange | null;
|
||||
}
|
||||
|
||||
interface TargetsResponse {
|
||||
targets: TargetItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Insights slug → target `type`. Mirrors `metric-target-summary.tsx`'s
|
||||
* map for the single-target metrics. Slugs absent from the map (HRV,
|
||||
* SpO₂, temperature, …) carry no in-target pct, so the bar suppresses.
|
||||
*/
|
||||
const SLUG_TO_TARGET_TYPE: Record<string, string> = {
|
||||
weight: "WEIGHT",
|
||||
bmi: "BMI",
|
||||
pulse: "PULSE",
|
||||
sleep: "SLEEP_DURATION",
|
||||
};
|
||||
|
||||
interface MetricPrimaryTileProps {
|
||||
/**
|
||||
* Analytics summary for the page's `MeasurementType`. Null is allowed
|
||||
* so the parent doesn't gate on the network read finishing.
|
||||
*/
|
||||
summary: DataSummary | null;
|
||||
/** Unit suffix rendered next to the value (e.g. `bpm`, `kg`). */
|
||||
unit: string;
|
||||
/**
|
||||
* Insights category slug (e.g. `"weight"`). When the slug maps to a
|
||||
* registered target, the tile reads the in-target pct from
|
||||
* `/api/insights/targets` and paints the "Im Zielbereich" bar. Omit
|
||||
* for metrics with no numeric target — the tile then shows headline +
|
||||
* 30-day average only.
|
||||
*/
|
||||
slug?: string;
|
||||
/** Decimal precision for the formatted values. Defaults to 1. */
|
||||
fractionDigits?: number;
|
||||
}
|
||||
|
||||
export function MetricPrimaryTile({
|
||||
summary,
|
||||
unit,
|
||||
slug,
|
||||
fractionDigits = 1,
|
||||
}: MetricPrimaryTileProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const targetType = slug ? SLUG_TO_TARGET_TYPE[slug] : undefined;
|
||||
|
||||
const { data: targets } = useQuery({
|
||||
queryKey: queryKeys.insightsTargets(),
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/insights/targets");
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as TargetsResponse;
|
||||
},
|
||||
enabled: isAuthenticated && targetType != null,
|
||||
});
|
||||
|
||||
// Nothing to show until the summary lands or for a metric with no
|
||||
// readings — the page's empty-state covers the zero-data case.
|
||||
if (!summary || summary.count <= 0) return null;
|
||||
|
||||
const headline =
|
||||
summary.latest === null
|
||||
? "—"
|
||||
: `${fmt.number(summary.latest, fractionDigits)} ${unit}`;
|
||||
const avg30 =
|
||||
summary.avg30 === null
|
||||
? null
|
||||
: `${fmt.number(summary.avg30, fractionDigits)} ${unit}`;
|
||||
|
||||
// In-target share: fraction of logged days in the green band over the
|
||||
// last 30, read verbatim from the targets route (never recomputed).
|
||||
const target = targetType
|
||||
? targets?.targets.find((entry) => entry.type === targetType)
|
||||
: undefined;
|
||||
const showShare =
|
||||
target != null &&
|
||||
target.range != null &&
|
||||
!target.insufficientData &&
|
||||
target.daysLogged30d > 0;
|
||||
const sharePct = showShare
|
||||
? Math.round((target.daysInRange30d / target.daysLogged30d) * 100)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="metric-primary-tile"
|
||||
className="bg-card border-border space-y-3 rounded-xl border p-4"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<p
|
||||
data-slot="metric-primary-value"
|
||||
className="text-3xl font-semibold tabular-nums"
|
||||
>
|
||||
{headline}
|
||||
</p>
|
||||
{avg30 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.subPage.primary.avg30Label")}: {avg30}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* "Im Zielbereich" bar — share of the last 30 logged days inside
|
||||
the target band. Self-suppresses for target-less metrics and on
|
||||
insufficient data. */}
|
||||
{sharePct != null ? (
|
||||
<div data-slot="metric-primary-in-range" className="space-y-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.subPage.primary.inRangeLabel")}
|
||||
</p>
|
||||
<p className="text-sm font-medium tabular-nums">{sharePct}%</p>
|
||||
</div>
|
||||
<div
|
||||
className="bg-muted h-2 w-full overflow-hidden rounded-full"
|
||||
role="img"
|
||||
aria-label={t("insights.subPage.primary.inRangeAria", {
|
||||
pct: sharePct,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-[width]",
|
||||
sharePct >= 70 ? "bg-dracula-green" : "bg-dracula-orange",
|
||||
)}
|
||||
style={{ width: `${Math.min(100, Math.max(0, sharePct))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -298,20 +298,19 @@ function TargetReferencePanel({
|
||||
|
||||
const sourceLink = getTargetSourceLink(target);
|
||||
|
||||
// In-target share: fraction of logged days in the green band over the
|
||||
// last 30. Suppressed when the route flagged insufficient data so the
|
||||
// sub-page suppresses the share line.
|
||||
const showShare = !target.insufficientData && target.daysLogged30d > 0;
|
||||
const sharePct = showShare
|
||||
? Math.round((target.daysInRange30d / target.daysLogged30d) * 100)
|
||||
: null;
|
||||
|
||||
// 30-day average. BP stitches the diastolic average into the familiar
|
||||
// `S/D` pair; everything else rounds to one decimal.
|
||||
// v1.12.0 — the in-target share (% of logged days in band) and the
|
||||
// 30-day average moved UP to `<MetricPrimaryTile>`, the canonical home
|
||||
// for the headline + 30-day average + "Im Zielbereich" bar (the
|
||||
// no-duplicate-info rule). This panel stays the band reference: the
|
||||
// range string, the status pill, the positional range bar (where
|
||||
// today's value sits inside the band — distinct from the % bar), the
|
||||
// 7-day consistency strip, and the guideline source. Blood pressure is
|
||||
// the exception: it has no `<MetricPrimaryTile>` (this richer panel IS
|
||||
// its primary tile), so it keeps the stitched S/D 30-day average inline.
|
||||
let averageLabel: string | null = null;
|
||||
if (target.average30 != null) {
|
||||
if (isBp && target.average30 != null) {
|
||||
const avgValue =
|
||||
isBp && bpDiastolic?.average30 != null
|
||||
bpDiastolic?.average30 != null
|
||||
? `${Math.round(target.average30)}/${Math.round(bpDiastolic.average30)}`
|
||||
: String(Math.round(target.average30 * 10) / 10);
|
||||
averageLabel = `${t("targets.average30d")} ${avgValue} ${unit}`;
|
||||
@@ -383,15 +382,12 @@ function TargetReferencePanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Row 3: in-target share + 30-day average. */}
|
||||
{sharePct != null || averageLabel ? (
|
||||
{/* Row 3: 30-day average (blood pressure only — every other metric
|
||||
surfaces its 30-day average in `<MetricPrimaryTile>` above the
|
||||
chart, so repeating it here would duplicate the figure). */}
|
||||
{averageLabel ? (
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs">
|
||||
{sharePct != null ? (
|
||||
<span>
|
||||
{t("insights.subPage.target.inTargetShare", { pct: sharePct })}
|
||||
</span>
|
||||
) : null}
|
||||
{averageLabel ? <span>{averageLabel}</span> : null}
|
||||
<span>{averageLabel}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -86,6 +86,15 @@ export interface SubPageShellProps {
|
||||
* min / max / median / mean. Omitted on the empty-state branch.
|
||||
*/
|
||||
statStrip?: ReactNode;
|
||||
/**
|
||||
* v1.12.0 — canonical "primary" zone rendered between the header and
|
||||
* the stat strip, the top of the metric-detail spine: the primary tile
|
||||
* (headline value + 30-Tage-Durchschnitt + "Im Zielbereich" bar) and
|
||||
* the "Letzte Messung" card. Each child self-suppresses when its data
|
||||
* is absent, so a sparse metric reads as a clean heading + chart. The
|
||||
* mother page and empty-state branches omit it.
|
||||
*/
|
||||
primary?: ReactNode;
|
||||
/**
|
||||
* v1.8.5 W4b — measurement-diversity nudge.
|
||||
*
|
||||
@@ -126,6 +135,7 @@ export function SubPageShell({
|
||||
explainerMetric,
|
||||
focusOnMount = false,
|
||||
statStrip,
|
||||
primary,
|
||||
diversityNudge,
|
||||
coachLaunch = false,
|
||||
showAllValuesType,
|
||||
@@ -253,6 +263,11 @@ export function SubPageShell({
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
) : null}
|
||||
</header>
|
||||
{/* v1.12.0 — the canonical "primary" zone leads the spine: the
|
||||
primary tile (headline + 30-Tage-Durchschnitt + Im-Zielbereich
|
||||
bar) and the "Letzte Messung" card, both above the stat strip.
|
||||
Each child self-suppresses when its data is absent. */}
|
||||
{primary}
|
||||
{/* v1.8.5 W4b — numbers-first stat strip sits between the header
|
||||
and the chart so the page leads with data, mirroring the Apple
|
||||
Health / Withings detail layout. Self-gating (renders nothing
|
||||
|
||||
@@ -169,7 +169,9 @@ describe("<SettingsShell>", () => {
|
||||
expect(html).toContain('href="/settings/thresholds"');
|
||||
expect(html).toContain('href="/settings/sources"');
|
||||
expect(html).toContain("Targets");
|
||||
expect(html).toContain("Sources");
|
||||
// v1.12.0 — the source-priority entry adopts the canonical
|
||||
// "Source priority" / "Quellen-Priorität" label (handover §5).
|
||||
expect(html).toContain("Source priority");
|
||||
});
|
||||
|
||||
it("resolves every section title via the i18n provider — German", () => {
|
||||
|
||||
@@ -41,6 +41,17 @@ import type { CorrelationResult } from "@/lib/insights/correlations";
|
||||
export interface SubPageAnalyticsData {
|
||||
/** `Record<MeasurementType, DataSummary>` — sentinel value for "not loaded yet" is `undefined`. */
|
||||
summaries: Record<string, DataSummary>;
|
||||
/**
|
||||
* v1.12.0 — per-type freshness map, also carried on the slim
|
||||
* `?slice=summaries` branch (`computeSummariesSlice`). The metric
|
||||
* detail page's "Letzte Messung" card reads
|
||||
* `lastSeenByType[type]?.lastSeenAt` to caption when the latest
|
||||
* reading landed, without paying for the thick analytics slice.
|
||||
*/
|
||||
lastSeenByType?: Record<
|
||||
string,
|
||||
{ lastSeenAt: string; daysAgo: number } | null
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user