From d5eb887757d3cbfdf18026847f700ff1ac08f55b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 23:01:57 +0200 Subject: [PATCH] feat(insights): adopt the canonical per-metric detail template and terminology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- messages/de.json | 16 +- messages/en.json | 16 +- messages/es.json | 16 +- messages/fr.json | 16 +- messages/it.json | 16 +- messages/pl.json | 16 +- src/app/insights/blood-pressure/page.tsx | 19 +- src/app/insights/pulse/page.tsx | 42 ++-- src/app/insights/sleep/page.tsx | 7 +- src/app/insights/weight/page.tsx | 15 ++ .../__tests__/metric-target-summary.test.tsx | 22 ++- .../insights/healthkit-metric-page.tsx | 31 ++- .../insights/metric-correlation-card.tsx | 95 +++++++++ .../insights/metric-last-measurement-card.tsx | 65 +++++++ .../insights/metric-primary-tile.tsx | 184 ++++++++++++++++++ .../insights/metric-target-summary.tsx | 36 ++-- src/components/insights/sub-page-shell.tsx | 15 ++ .../__tests__/settings-shell.test.tsx | 4 +- src/types/analytics.ts | 11 ++ 19 files changed, 564 insertions(+), 78 deletions(-) create mode 100644 src/components/insights/metric-correlation-card.tsx create mode 100644 src/components/insights/metric-last-measurement-card.tsx create mode 100644 src/components/insights/metric-primary-tile.tsx diff --git a/messages/de.json b/messages/de.json index bcd2f4415..209bca68d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 9a457a19d..f9a9b2714 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 2d03776f1..3791708a4 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 9c549e0aa..89c1a58f4 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 6c49f7f00..a97e68dd6 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 0efee5f9c..ac66f6cba 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index 994bfbc96..12553fc69 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -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. */} + + + + } diversityNudge={ - - + {/* v1.12.0 — Einschätzung is the last block on the canonical + metric-detail spine. */} } diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index fb7ab30c7..b2186faba 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -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={ - + primary={ + <> + + + } + statStrip={} diversityNudge={ - } - 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). */} + + + {/* v1.12.0 — Einschätzung is the last block on the canonical + metric-detail spine. */} + } + text={status?.text ?? null} + hasProvider={status?.hasProvider ?? false} + updatedAt={status?.updatedAt ?? null} + loading={isStatusLoading} + preparing={status?.preparing ?? false} + /> ); } diff --git a/src/app/insights/sleep/page.tsx b/src/app/insights/sleep/page.tsx index ca88e2191..116422bc8 100644 --- a/src/app/insights/sleep/page.tsx +++ b/src/app/insights/sleep/page.tsx @@ -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={} diversityNudge={ + + + + } statStrip={} diversityNudge={ + {/* v1.12.0 — Weight owns the weight × weekday correlation (relocated + off the overview onto its metric page). */} + + } diff --git a/src/components/insights/__tests__/metric-target-summary.test.tsx b/src/components/insights/__tests__/metric-target-summary.test.tsx index 3ddf78249..10faa398e 100644 --- a/src/components/insights/__tests__/metric-target-summary.test.tsx +++ b/src/components/insights/__tests__/metric-target-summary.test.tsx @@ -154,12 +154,15 @@ const GLUCOSE_DATA: TargetsFixture = { }; describe("", () => { - 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 ``, + // 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("", () => { ); }); - 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 `` 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"); }); diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index a999d91ef..9e3547344 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -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={ + <> + + + + } statStrip={} diversityNudge={ ) : null} - {statusMetric ? ( - } - enabled={!isEmpty} - /> - ) : null} {forecast && isTrajectoryType(measurementType) ? ( ) : null} + {/* v1.12.0 — Einschätzung is the last block on the canonical + metric-detail spine. */} + {statusMetric ? ( + } + enabled={!isEmpty} + /> + ) : null} ); } diff --git a/src/components/insights/metric-correlation-card.tsx b/src/components/insights/metric-correlation-card.tsx new file mode 100644 index 000000000..0ee1a97ee --- /dev/null +++ b/src/components/insights/metric-correlation-card.tsx @@ -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 `` 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 + * `` carries its own scatter + interpretation + source. + */ + +interface AnalyticsWithCorrelations { + correlations?: Partial>; +} + +/** Insights slug → the `correlations` payload key it owns. */ +const SLUG_TO_CORRELATION: Record = { + weight: "weightWeekday", + pulse: "moodPulse", +}; + +/** The `kind` the owned result must carry — guards a payload-key drift. */ +const SLUG_TO_KIND: Record = { + 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 ( +
+

+ {t("insights.correlationRow.title")} +

+ +

+ {t("insights.correlationRow.disclaimer")} +

+
+ ); +} diff --git a/src/components/insights/metric-last-measurement-card.tsx b/src/components/insights/metric-last-measurement-card.tsx new file mode 100644 index 000000000..997383cf5 --- /dev/null +++ b/src/components/insights/metric-last-measurement-card.tsx @@ -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 `` 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 ( +
+
+
+
+

+ {t("insights.subPage.lastMeasurement.label")} +

+

+ {fmt.dateWithWeekday(parsed)} + + {formatRelativeTime(lastSeenAt, t)} + +

+
+
+ ); +} diff --git a/src/components/insights/metric-primary-tile.tsx b/src/components/insights/metric-primary-tile.tsx new file mode 100644 index 000000000..7594ef0dc --- /dev/null +++ b/src/components/insights/metric-primary-tile.tsx @@ -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 + * ``). + */ + +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 = { + 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 ( +
+
+

+ {headline} +

+ {avg30 ? ( +

+ {t("insights.subPage.primary.avg30Label")}: {avg30} +

+ ) : null} +
+ + {/* "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 ? ( +
+
+

+ {t("insights.subPage.primary.inRangeLabel")} +

+

{sharePct}%

+
+
+
= 70 ? "bg-dracula-green" : "bg-dracula-orange", + )} + style={{ width: `${Math.min(100, Math.max(0, sharePct))}%` }} + /> +
+
+ ) : null} +
+ ); +} diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx index da6709f4a..5599dfeea 100644 --- a/src/components/insights/metric-target-summary.tsx +++ b/src/components/insights/metric-target-summary.tsx @@ -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 ``, 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 `` (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({ ) : 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 `` above the + chart, so repeating it here would duplicate the figure). */} + {averageLabel ? (
- {sharePct != null ? ( - - {t("insights.subPage.target.inTargetShare", { pct: sharePct })} - - ) : null} - {averageLabel ? {averageLabel} : null} + {averageLabel}
) : null} diff --git a/src/components/insights/sub-page-shell.tsx b/src/components/insights/sub-page-shell.tsx index 7eefc2203..913234737 100644 --- a/src/components/insights/sub-page-shell.tsx +++ b/src/components/insights/sub-page-shell.tsx @@ -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({

{description}

) : null} + {/* 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 diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index f1f43102e..5584e3330 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -169,7 +169,9 @@ describe("", () => { 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", () => { diff --git a/src/types/analytics.ts b/src/types/analytics.ts index 61ef34f54..5428ccea2 100644 --- a/src/types/analytics.ts +++ b/src/types/analytics.ts @@ -41,6 +41,17 @@ import type { CorrelationResult } from "@/lib/insights/correlations"; export interface SubPageAnalyticsData { /** `Record` — sentinel value for "not loaded yet" is `undefined`. */ summaries: Record; + /** + * 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 + >; } /**