From 1f01b941ae4e797389c623d3c2e230925c1dd98a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 15:53:18 +0200 Subject: [PATCH] feat(insights): chart cards for HRV, resting HR, SpO2, body temperature, active energy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds five sub-pages for the wave-A HealthKit metrics the server has been storing since v1.4.23 / v1.4.25 but never surfaced on the web: - /insights/hrv → HEART_RATE_VARIABILITY - /insights/ruhepuls → RESTING_HEART_RATE - /insights/sauerstoff → OXYGEN_SATURATION - /insights/koerpertemperatur → BODY_TEMPERATURE - /insights/aktive-energie → ACTIVE_ENERGY_BURNED Each page composes the shared `` scaffold — `SubPageShell` + `HealthChartDynamic` mounted on its own `ChartOverlayKey` slot + `CoachLaunchButton` + an empty-state CTA that points at the existing `/measurements?add=` quick-entry where a manual entry makes sense, or an Apple-Health-onboarding cue otherwise. The five new chart-overlay-prefs slots (`hrv`, `restingHr`, `oxygenSaturation`, `bodyTemperature`, `activeEnergy`) let the chart-cog popover persist per metric. Insights tab strip picks up the five new pills via the existing `SUB_PAGE_METRIC` + `InsightMetric` enums. The wave-A categorisation overlay realigns HRV + resting HR to the `cardiovascular` bucket per the iOS handoff brief (PULSE stays in `vitals`; spot-pulse is a point-in-time signal, derived heart-rate variants are the cardiac risk surface). Six locales updated with the new sub-page titles, descriptions, chart titles, and empty-state copy. --- messages/de.json | 50 +++++++ messages/en.json | 50 +++++++ messages/es.json | 50 +++++++ messages/fr.json | 50 +++++++ messages/it.json | 50 +++++++ messages/pl.json | 50 +++++++ src/app/insights/aktive-energie/page.tsx | 31 ++++ src/app/insights/hrv/page.tsx | 31 ++++ src/app/insights/koerpertemperatur/page.tsx | 30 ++++ src/app/insights/ruhepuls/page.tsx | 31 ++++ src/app/insights/sauerstoff/page.tsx | 40 +++++ .../insights/healthkit-metric-page.tsx | 139 ++++++++++++++++++ src/lib/dashboard-layout.ts | 8 + src/lib/measurements/categories.ts | 12 +- 14 files changed, 619 insertions(+), 3 deletions(-) create mode 100644 src/app/insights/aktive-energie/page.tsx create mode 100644 src/app/insights/hrv/page.tsx create mode 100644 src/app/insights/koerpertemperatur/page.tsx create mode 100644 src/app/insights/ruhepuls/page.tsx create mode 100644 src/app/insights/sauerstoff/page.tsx create mode 100644 src/components/insights/healthkit-metric-page.tsx diff --git a/messages/de.json b/messages/de.json index 02eaf4e21..7d94e9cab 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1118,6 +1118,56 @@ "navBodyTemperature": "Temperatur", "navActiveEnergy": "Aktive Energie", "navAriaLabel": "Zu Abschnitt springen", + "hrv": { + "title": "HRV", + "description": "Herzratenvariabilität im Verlauf — dein Erholungssignal des autonomen Nervensystems, nächtlich von Apple Watch und kompatiblen Wearables erfasst.", + "chartTitle": "Herzratenvariabilität", + "emptyState": { + "title": "Noch keine HRV-Werte", + "description": "HRV-Werte erscheinen hier, sobald die Apple Watch (oder ein kompatibles Withings-Gerät) nächtliche Messungen synchronisiert.", + "cta": "" + } + }, + "restingHr": { + "title": "Ruhepuls", + "description": "Die tägliche Grundlinie deines Herzschlags — unterschieden vom Spot-Puls. Apple Health liefert einen Wert pro Tag.", + "chartTitle": "Ruhepuls", + "emptyState": { + "title": "Noch kein Ruhepuls", + "description": "Der Ruhepuls erscheint, sobald die Apple Watch oder ein kompatibles Withings-Gerät einen ruhigen Tag durchgetragen hat.", + "cta": "" + } + }, + "oxygenSaturation": { + "title": "Sauerstoffsättigung", + "description": "Blutsauerstoff (SpO₂) — klinischer Normalbereich 95–100 %; Zielzonen sind am Chart hinterlegt.", + "chartTitle": "Sauerstoffsättigung", + "emptyState": { + "title": "Noch keine SpO₂-Werte", + "description": "SpO₂-Werte erscheinen, sobald ein kompatibles Wearable (Apple Watch, Withings ScanWatch) eine Messung erfasst.", + "cta": "" + } + }, + "bodyTemperature": { + "title": "Körpertemperatur", + "description": "Verlauf der Körperkerntemperatur in Celsius. Hauttemperaturwerte von Withings laufen über eine separate Metrik.", + "chartTitle": "Körpertemperatur", + "emptyState": { + "title": "Noch keine Körpertemperatur", + "description": "Erfasse eine Körpertemperatur oder lass dein Wearable einen Wert synchronisieren — der Verlauf erscheint dann hier.", + "cta": "Körpertemperatur erfassen" + } + }, + "activeEnergy": { + "title": "Aktive Energie", + "description": "Tägliche aktive-Energie-Summe in Kilokalorien — eine Zeile pro Tag, aus Apple Health oder Withings-Aktivität.", + "chartTitle": "Aktive Energie", + "emptyState": { + "title": "Noch keine Aktive-Energie-Werte", + "description": "Aktive-Energie-Summen erscheinen hier, sobald Apple Health oder ein Withings-Aktivitätsgerät einen Tagesumfang synchronisiert.", + "cta": "" + } + }, "subPage": { "blutdruckDescription": "Dein systolisch-/diastolisch-Verlauf mit altersangepassten Zielbereichen und einer textbasierten KI-Einschätzung.", "gewichtDescription": "Gewichtsverlauf gegen das aus deiner Größe abgeleitete gesunde Band, mit KI-Einschätzung.", diff --git a/messages/en.json b/messages/en.json index 930a8d047..4087254fc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1118,6 +1118,56 @@ "navBodyTemperature": "Temperature", "navActiveEnergy": "Active energy", "navAriaLabel": "Skip to section", + "hrv": { + "title": "HRV", + "description": "Heart-rate variability over time — your nervous-system recovery signal, captured during sleep by Apple Watch and other compatible wearables.", + "chartTitle": "Heart-rate variability", + "emptyState": { + "title": "No HRV samples yet", + "description": "HRV samples land here once Apple Watch (or a compatible Withings device) starts syncing nightly readings.", + "cta": "" + } + }, + "restingHr": { + "title": "Resting heart rate", + "description": "The daily floor your heart settles at — distinct from the spot pulse samples on the pulse page. Apple Health surfaces one value per day.", + "chartTitle": "Resting heart rate", + "emptyState": { + "title": "No resting heart rate yet", + "description": "Resting heart rate arrives once Apple Watch or a compatible Withings device has worn through one quiet day.", + "cta": "" + } + }, + "oxygenSaturation": { + "title": "Oxygen saturation", + "description": "Blood oxygen (SpO₂) — clinical normal is 95–100 %, with target zones shaded on the chart.", + "chartTitle": "Oxygen saturation", + "emptyState": { + "title": "No SpO₂ samples yet", + "description": "SpO₂ samples land here once a compatible wearable (Apple Watch, Withings ScanWatch) captures a reading.", + "cta": "" + } + }, + "bodyTemperature": { + "title": "Body temperature", + "description": "Core body temperature trend — Celsius. Skin-temperature readings from Withings live on a separate metric.", + "chartTitle": "Body temperature", + "emptyState": { + "title": "No body temperature yet", + "description": "Log a body-temperature reading or let your wearable sync one, and the trend will appear here.", + "cta": "Log body temperature" + } + }, + "activeEnergy": { + "title": "Active energy", + "description": "Daily active-energy total in kilocalories — one row per day, sourced from Apple Health or Withings activity.", + "chartTitle": "Active energy", + "emptyState": { + "title": "No active-energy data yet", + "description": "Active-energy totals land here once Apple Health or a Withings activity device syncs a day's worth of movement.", + "cta": "" + } + }, "subPage": { "blutdruckDescription": "Your systolic / diastolic trend with age-personalised target zones and a written AI assessment.", "gewichtDescription": "Weight trend mapped against your height-derived healthy band, plus an AI assessment.", diff --git a/messages/es.json b/messages/es.json index 29809030d..a7013c92d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1118,6 +1118,56 @@ "navBodyTemperature": "Temperatura", "navActiveEnergy": "Energía activa", "navAriaLabel": "Skip to section", + "hrv": { + "title": "VFC", + "description": "Variabilidad de la frecuencia cardíaca a lo largo del tiempo — señal de recuperación del sistema nervioso, captada por la noche por Apple Watch y otros wearables compatibles.", + "chartTitle": "Variabilidad de la frecuencia cardíaca", + "emptyState": { + "title": "Aún no hay valores de VFC", + "description": "Los valores de VFC aparecerán aquí en cuanto Apple Watch (o un dispositivo Withings compatible) sincronice las mediciones nocturnas.", + "cta": "" + } + }, + "restingHr": { + "title": "FC en reposo", + "description": "El nivel basal diario del corazón — distinto del pulso puntual de la página de pulso. Apple Salud aporta un valor al día.", + "chartTitle": "Frecuencia cardíaca en reposo", + "emptyState": { + "title": "Aún no hay FC en reposo", + "description": "La FC en reposo aparece cuando Apple Watch o un Withings compatible ha llevado puesto un día tranquilo.", + "cta": "" + } + }, + "oxygenSaturation": { + "title": "Saturación de oxígeno", + "description": "Oxígeno en sangre (SpO₂) — rango clínico normal 95–100 %, con zonas objetivo sombreadas en el gráfico.", + "chartTitle": "Saturación de oxígeno", + "emptyState": { + "title": "Aún no hay muestras de SpO₂", + "description": "Las muestras de SpO₂ llegan aquí en cuanto un wearable compatible (Apple Watch, Withings ScanWatch) capture una lectura.", + "cta": "" + } + }, + "bodyTemperature": { + "title": "Temperatura corporal", + "description": "Tendencia de temperatura corporal central en grados Celsius. La temperatura cutánea de Withings vive en una métrica distinta.", + "chartTitle": "Temperatura corporal", + "emptyState": { + "title": "Aún no hay temperatura corporal", + "description": "Registra una lectura o deja que tu wearable sincronice una — la tendencia aparecerá aquí.", + "cta": "Registrar temperatura corporal" + } + }, + "activeEnergy": { + "title": "Energía activa", + "description": "Total diario de energía activa en kilocalorías — una fila por día, desde Apple Salud o la actividad de Withings.", + "chartTitle": "Energía activa", + "emptyState": { + "title": "Aún no hay datos de energía activa", + "description": "Los totales de energía activa aparecerán aquí en cuanto Apple Salud o un dispositivo de actividad Withings sincronice un día completo.", + "cta": "" + } + }, "subPage": { "blutdruckDescription": "Your systolic / diastolic trend with age-personalised target zones and a written AI assessment.", "gewichtDescription": "Weight trend mapped against your height-derived healthy band, plus an AI assessment.", diff --git a/messages/fr.json b/messages/fr.json index ad78d7b30..b4d229b1f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1118,6 +1118,56 @@ "navBodyTemperature": "Température", "navActiveEnergy": "Énergie active", "navAriaLabel": "Skip to section", + "hrv": { + "title": "VFC", + "description": "Variabilité de la fréquence cardiaque dans le temps — signal de récupération du système nerveux, capté la nuit par l'Apple Watch et d'autres wearables compatibles.", + "chartTitle": "Variabilité de la fréquence cardiaque", + "emptyState": { + "title": "Aucune mesure de VFC", + "description": "Les mesures de VFC apparaîtront ici dès que l'Apple Watch (ou un appareil Withings compatible) synchronisera des relevés nocturnes.", + "cta": "" + } + }, + "restingHr": { + "title": "FC au repos", + "description": "La fréquence cardiaque de base sur la journée — distincte des pointages ponctuels de la page Pulse. Apple Santé fournit une valeur par jour.", + "chartTitle": "Fréquence cardiaque au repos", + "emptyState": { + "title": "Aucune FC au repos", + "description": "La FC au repos arrive dès que l'Apple Watch ou un appareil Withings compatible a porté une journée calme.", + "cta": "" + } + }, + "oxygenSaturation": { + "title": "Saturation en oxygène", + "description": "Oxygène sanguin (SpO₂) — plage clinique normale 95–100 %, avec zones cibles ombrées sur le graphique.", + "chartTitle": "Saturation en oxygène", + "emptyState": { + "title": "Aucune mesure SpO₂", + "description": "Les mesures SpO₂ apparaîtront dès qu'un wearable compatible (Apple Watch, Withings ScanWatch) en capturera une.", + "cta": "" + } + }, + "bodyTemperature": { + "title": "Température corporelle", + "description": "Tendance de température corporelle centrale en Celsius. La température cutanée Withings vit sur une métrique distincte.", + "chartTitle": "Température corporelle", + "emptyState": { + "title": "Aucune température corporelle", + "description": "Enregistre une mesure ou laisse ton wearable en synchroniser une — la tendance apparaîtra ici.", + "cta": "Enregistrer la température" + } + }, + "activeEnergy": { + "title": "Énergie active", + "description": "Total quotidien d'énergie active en kilocalories — une ligne par jour, depuis Apple Santé ou Withings activité.", + "chartTitle": "Énergie active", + "emptyState": { + "title": "Aucune donnée d'énergie active", + "description": "Les totaux d'énergie active apparaîtront dès qu'Apple Santé ou un appareil Withings activité synchronise une journée complète.", + "cta": "" + } + }, "subPage": { "blutdruckDescription": "Your systolic / diastolic trend with age-personalised target zones and a written AI assessment.", "gewichtDescription": "Weight trend mapped against your height-derived healthy band, plus an AI assessment.", diff --git a/messages/it.json b/messages/it.json index e4065b941..f40fedee0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1118,6 +1118,56 @@ "navBodyTemperature": "Temperatura", "navActiveEnergy": "Energia attiva", "navAriaLabel": "Skip to section", + "hrv": { + "title": "HRV", + "description": "Variabilità della frequenza cardiaca nel tempo — segnale di recupero del sistema nervoso, captato di notte da Apple Watch e altri wearable compatibili.", + "chartTitle": "Variabilità della frequenza cardiaca", + "emptyState": { + "title": "Ancora nessun valore HRV", + "description": "I valori HRV appariranno qui non appena Apple Watch (o un dispositivo Withings compatibile) sincronizza le misurazioni notturne.", + "cta": "" + } + }, + "restingHr": { + "title": "FC a riposo", + "description": "Il livello cardiaco di base sulla giornata — distinto dalle frequenze puntuali della pagina Polso. Apple Salute fornisce un valore al giorno.", + "chartTitle": "Frequenza cardiaca a riposo", + "emptyState": { + "title": "Ancora nessuna FC a riposo", + "description": "La FC a riposo arriva quando Apple Watch o un Withings compatibile ha indossato una giornata tranquilla.", + "cta": "" + } + }, + "oxygenSaturation": { + "title": "Saturazione di ossigeno", + "description": "Ossigeno nel sangue (SpO₂) — intervallo clinico normale 95–100 %, con zone obiettivo ombreggiate sul grafico.", + "chartTitle": "Saturazione di ossigeno", + "emptyState": { + "title": "Ancora nessun campione SpO₂", + "description": "I campioni SpO₂ appariranno qui non appena un wearable compatibile (Apple Watch, Withings ScanWatch) cattura una lettura.", + "cta": "" + } + }, + "bodyTemperature": { + "title": "Temperatura corporea", + "description": "Andamento della temperatura corporea centrale in Celsius. La temperatura cutanea di Withings vive su una metrica separata.", + "chartTitle": "Temperatura corporea", + "emptyState": { + "title": "Ancora nessuna temperatura corporea", + "description": "Registra una misura o lascia che il tuo wearable ne sincronizzi una — l'andamento apparirà qui.", + "cta": "Registra temperatura corporea" + } + }, + "activeEnergy": { + "title": "Energia attiva", + "description": "Totale giornaliero di energia attiva in kilocalorie — una riga al giorno, da Apple Salute o dall'attività Withings.", + "chartTitle": "Energia attiva", + "emptyState": { + "title": "Ancora nessun dato di energia attiva", + "description": "I totali di energia attiva appariranno qui non appena Apple Salute o un dispositivo di attività Withings sincronizza un'intera giornata.", + "cta": "" + } + }, "subPage": { "blutdruckDescription": "Your systolic / diastolic trend with age-personalised target zones and a written AI assessment.", "gewichtDescription": "Weight trend mapped against your height-derived healthy band, plus an AI assessment.", diff --git a/messages/pl.json b/messages/pl.json index db1c5a183..722774831 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1118,6 +1118,56 @@ "navBodyTemperature": "Temperatura", "navActiveEnergy": "Energia czynna", "navAriaLabel": "Skip to section", + "hrv": { + "title": "HRV", + "description": "Zmienność rytmu serca w czasie — sygnał regeneracji układu nerwowego, rejestrowany nocą przez Apple Watch i inne zgodne wearable.", + "chartTitle": "Zmienność rytmu serca", + "emptyState": { + "title": "Brak wartości HRV", + "description": "Wartości HRV pojawią się tutaj, gdy Apple Watch (lub zgodne urządzenie Withings) zsynchronizuje pomiary nocne.", + "cta": "" + } + }, + "restingHr": { + "title": "Tętno spoczynkowe", + "description": "Dzienna linia bazowa rytmu serca — różna od tętna punktowego na stronie Pulse. Apple Zdrowie podaje jedną wartość na dobę.", + "chartTitle": "Tętno spoczynkowe", + "emptyState": { + "title": "Brak tętna spoczynkowego", + "description": "Tętno spoczynkowe pojawi się, gdy Apple Watch lub zgodne urządzenie Withings spędzi spokojny dzień na nadgarstku.", + "cta": "" + } + }, + "oxygenSaturation": { + "title": "Saturacja tlenu", + "description": "Tlen we krwi (SpO₂) — kliniczny zakres normy 95–100 %, ze strefami docelowymi zacienionymi na wykresie.", + "chartTitle": "Saturacja tlenu", + "emptyState": { + "title": "Brak próbek SpO₂", + "description": "Próbki SpO₂ trafią tutaj, gdy zgodne wearable (Apple Watch, Withings ScanWatch) wykona pomiar.", + "cta": "" + } + }, + "bodyTemperature": { + "title": "Temperatura ciała", + "description": "Trend temperatury rdzenia ciała w stopniach Celsjusza. Temperatura skóry z Withings działa na osobnej metryce.", + "chartTitle": "Temperatura ciała", + "emptyState": { + "title": "Brak temperatury ciała", + "description": "Zarejestruj pomiar lub pozwól wearable zsynchronizować jeden — trend pojawi się tutaj.", + "cta": "Zarejestruj temperaturę ciała" + } + }, + "activeEnergy": { + "title": "Energia czynna", + "description": "Dzienna suma energii czynnej w kilokaloriach — jeden wiersz na dobę, z Apple Zdrowie lub aktywności Withings.", + "chartTitle": "Energia czynna", + "emptyState": { + "title": "Brak danych o energii czynnej", + "description": "Sumy energii czynnej pojawią się tutaj, gdy Apple Zdrowie lub urządzenie Withings zsynchronizuje pełny dzień.", + "cta": "" + } + }, "subPage": { "blutdruckDescription": "Your systolic / diastolic trend with age-personalised target zones and a written AI assessment.", "gewichtDescription": "Weight trend mapped against your height-derived healthy band, plus an AI assessment.", diff --git a/src/app/insights/aktive-energie/page.tsx b/src/app/insights/aktive-energie/page.tsx new file mode 100644 index 000000000..146d81cfd --- /dev/null +++ b/src/app/insights/aktive-energie/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Flame } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.4.32 — `/insights/aktive-energie`. + * + * Active-energy-burned sub-page. The MeasurementType is cumulative + * (kcal per day, per R-A Option A); the v1.4.30 server-side daily- + * stats helper collapses per-sample iOS posts to a single daily row. + * The chart shows that one-row-per-day shape — long-tail back-fill + * imports collapse cleanly via the v1.4.30 drain script. + */ +export default function InsightsActiveEnergyPage() { + return ( + } + emptyStateCtaType={null} + coachPrefill="I haven't logged any active energy yet — what's a reasonable daily target, and how does it compare to total calories?" + /> + ); +} diff --git a/src/app/insights/hrv/page.tsx b/src/app/insights/hrv/page.tsx new file mode 100644 index 000000000..5076fbaee --- /dev/null +++ b/src/app/insights/hrv/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Activity } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.4.32 — `/insights/hrv`. + * + * Heart-rate variability sub-page. Pairs with the iOS HKQuantityType + * `heartRateVariabilitySDNN` ingest path; the user typically logs one + * sample per night during sleep, so the chart is sparse but the + * weekly trend is the load-bearing signal. Stored as millisecond + * `HEART_RATE_VARIABILITY` rows by every existing ingest path. + */ +export default function InsightsHrvPage() { + return ( + } + emptyStateCtaType={null} + coachPrefill="I haven't logged any HRV data yet — what does heart-rate variability tell me, and how do I capture it?" + /> + ); +} diff --git a/src/app/insights/koerpertemperatur/page.tsx b/src/app/insights/koerpertemperatur/page.tsx new file mode 100644 index 000000000..0d7657ce1 --- /dev/null +++ b/src/app/insights/koerpertemperatur/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { Thermometer } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.4.32 — `/insights/koerpertemperatur`. + * + * Body-temperature sub-page. Canonical storage is Celsius. Distinct + * from skin temperature (Withings ScanWatch dermal reading) — surface + * temps run ~32 °C, core ~37 °C, so the schema deliberately keeps + * them on separate enum values per the v1.4.30 categorisation overlay. + */ +export default function InsightsBodyTemperaturePage() { + return ( + } + emptyStateCtaType="BODY_TEMPERATURE" + coachPrefill="I haven't logged any body temperature yet — what's the healthy range, and what should I do if it drifts?" + /> + ); +} diff --git a/src/app/insights/ruhepuls/page.tsx b/src/app/insights/ruhepuls/page.tsx new file mode 100644 index 000000000..dff1f1e79 --- /dev/null +++ b/src/app/insights/ruhepuls/page.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Heart } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.4.32 — `/insights/ruhepuls`. + * + * Resting-heart-rate sub-page. Distinct from `/insights/puls` — Apple + * Health splits the steady-state daily `restingHeartRate` value from + * the spot `PULSE` samples logged manually or by Withings. The split + * here mirrors that distinction so the user has a "daily floor" + * trend that's not muddied by mid-walk readings. + */ +export default function InsightsRestingHrPage() { + return ( + } + emptyStateCtaType={null} + coachPrefill="I haven't logged any resting heart rate yet — why is the daily resting value different from a spot pulse, and how do I improve it?" + /> + ); +} diff --git a/src/app/insights/sauerstoff/page.tsx b/src/app/insights/sauerstoff/page.tsx new file mode 100644 index 000000000..4309e9abd --- /dev/null +++ b/src/app/insights/sauerstoff/page.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { Wind } from "lucide-react"; + +import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page"; + +/** + * v1.4.32 — `/insights/sauerstoff`. + * + * Oxygen-saturation (SpO₂) sub-page. Canonical storage is percent + * (0..100); the chart axis runs 85..100 because clinical normal is + * 95..100 and the 85 floor leaves room for nightly dips without + * compressing the trend. Withings ScanWatch + Apple Watch both + * report SpO₂; the v1.4.25 source-priority picker keeps the surface + * deduped. + */ +export default function InsightsOxygenSaturationPage() { + return ( + } + emptyStateCtaType={null} + coachPrefill="I haven't logged any oxygen-saturation data yet — what does SpO₂ tell me, and what range should I aim for?" + /> + ); +} diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx new file mode 100644 index 000000000..16e323c2a --- /dev/null +++ b/src/components/insights/healthkit-metric-page.tsx @@ -0,0 +1,139 @@ +"use client"; + +import Link from "next/link"; +import type { ComponentProps, ReactNode } from "react"; + +import { useAuth } from "@/hooks/use-auth"; +import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; +import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; +import { useTranslations } from "@/lib/i18n/context"; +import type { InsightMetric } from "@/lib/insights/metric-availability"; +import type { ChartOverlayKey } from "@/lib/dashboard-layout"; +import { Button } from "@/components/ui/button"; +import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; +import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; +import { MetricEmptyState } from "@/components/insights/metric-empty-state"; +import { SubPageShell } from "@/components/insights/sub-page-shell"; + +/** + * v1.4.32 — shared scaffold for the wave-A HealthKit metric pages. + * + * Each of the five new metric sub-pages (HRV, resting HR, oxygen + * saturation, body temperature, active energy) shares the same + * envelope: + * + * 1. `` with a localised title + description. + * 2. Empty-state CTA when the user has no observations for the + * metric — points at `/measurements?add=` so the existing + * quick-entry dialog can light up. The CTA target is optional; + * Apple-Health-only metrics (active energy, body temperature) + * pass `null` to render an onboarding hint without a dead link. + * 3. `` mounted on the canonical + * `ChartOverlayKey` so the chart-cog popover persists per metric. + * 4. `` at the foot — feature-flag gating runs + * inside the button so this scaffold stays agnostic. + * + * Adding a new metric is a four-line page module that hands the right + * Zod / MeasurementType + ChartOverlayKey + i18n-key prefix to this + * component; the template stays single-source-of-truth. + */ + +export interface HealthKitMetricPageProps { + /** The MeasurementType that backs the chart. */ + measurementType: string; + /** The InsightMetric key used by `useInsightsAnalytics()`. */ + insightMetric: InsightMetric; + /** The chart-overlay slot id. */ + chartKey: ChartOverlayKey; + /** + * i18n key prefix that drives `title`, `description`, `chartTitle`, + * `emptyState.title`, `emptyState.description`, `emptyState.cta`. + * Pages pass e.g. `"insights.hrv"` and the scaffold resolves each + * sub-key off the prefix. + */ + i18nPrefix: string; + /** Single canonical colour for the chart line. */ + color: string; + /** Unit suffix the chart renders next to the value. */ + unit: string; + /** Optional y-axis label shown above the unit. */ + yAxisUnit?: string; + /** Optional value bands (Apple-Health-style target zone shading). */ + valueBands?: ComponentProps["valueBands"]; + /** + * Optional empty-state CTA target. `null` renders the empty state + * without a primary action (Apple-Health-only metrics that have no + * manual entry form). String values land in `/measurements?add=`. + */ + emptyStateCtaType?: string | null; + /** Icon node mounted in the empty-state card. */ + emptyStateIcon: ReactNode; + /** + * Optional Coach prefill for the empty-state launch. Falls back to a + * generic onboarding prompt threaded through ``'s + * default. + */ + coachPrefill?: string; +} + +export function HealthKitMetricPage({ + measurementType, + insightMetric, + chartKey, + i18nPrefix, + color, + unit, + yAxisUnit, + valueBands, + emptyStateCtaType, + emptyStateIcon, + coachPrefill, +}: HealthKitMetricPageProps) { + const { user, isAuthenticated } = useAuth(); + const { t } = useTranslations(); + const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated); + + const { isEmpty } = useInsightsAnalytics(insightMetric); + + const title = t(`${i18nPrefix}.title`); + const description = t(`${i18nPrefix}.description`); + + if (isEmpty) { + const ctaNode = + emptyStateCtaType != null ? ( + + ) : null; + return ( + + + + ); + } + + return ( + + + + + ); +} diff --git a/src/lib/dashboard-layout.ts b/src/lib/dashboard-layout.ts index fb6e73331..efa564769 100644 --- a/src/lib/dashboard-layout.ts +++ b/src/lib/dashboard-layout.ts @@ -131,6 +131,14 @@ export const CHART_OVERLAY_KEYS = [ // overlay-prefs slot so the cardio sub-page's VO2 chart-cog state // doesn't bleed into the pulse chart sitting directly above it. "vo2Max", + // v1.4.32 — wave-A HealthKit chart cards. Each one owns its own + // overlay-prefs slot so the chart-cog popover (7-day trend / Trend + // arrow / Target range / Comparison baseline) persists per metric. + "hrv", + "restingHr", + "oxygenSaturation", + "bodyTemperature", + "activeEnergy", ] as const; export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number]; diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index a522aafae..fc1876242 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -62,8 +62,6 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< ["BLOOD_PRESSURE_SYS", "vitals"], ["BLOOD_PRESSURE_DIA", "vitals"], ["PULSE", "vitals"], - ["RESTING_HEART_RATE", "vitals"], - ["HEART_RATE_VARIABILITY", "vitals"], ["OXYGEN_SATURATION", "vitals"], ["BODY_TEMPERATURE", "vitals"], @@ -96,7 +94,15 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap< // ── Environment ── ["TIME_IN_DAYLIGHT", "environment"], - // ── Cardiovascular (derived risk markers) ── + // ── Cardiovascular (derived risk markers + Apple-Health-grouped + // heart-rate variants). v1.4.32 — RESTING_HEART_RATE + + // HEART_RATE_VARIABILITY moved here from `vitals` to align with the + // iOS handoff brief's category table (RESTING_HR, HRV → + // cardiovascular). PULSE stays in `vitals` because spot-pulse + // samples are point-in-time signals; the resting + variability + // metrics are the derived cardiac-risk surface. ── + ["RESTING_HEART_RATE", "cardiovascular"], + ["HEART_RATE_VARIABILITY", "cardiovascular"], ["PULSE_WAVE_VELOCITY", "cardiovascular"], ["VASCULAR_AGE", "cardiovascular"],