diff --git a/CHANGELOG.md b/CHANGELOG.md index b9a900ef1..97d71f7b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ ## [Unreleased] +## [1.12.6] — 2026-06-05 — one consistent Insights surface + +### Changed + +- **Every Insights card now reads the same way: an icon, a title, then the content.** Targets, the systolic and diastolic stats, the wellness and vital tiles, mood, and medications all lead with the same titled header in the same weight, so the surface is easier to scan and find your place in. The target tile is simply titled "Target". +- **The wellness scores sit above the daily briefing, with legible numbers.** The score numbers now render in the foreground colour against the dark cards (the band still shows on the ring), and the strip leads the overview above the briefing. +- **The min / max / median / average stats moved above each chart.** They now lead the metric page rather than trailing it, and blood pressure shows the full stat strip for both systolic and diastolic. +- **The mood page leads with where you stand, then what fits your better days.** The classification (in-range share and stability) comes first, followed by the better-days read; the mood colours are aligned to the rest of the charts. +- **The medications page is tighter.** The dose-by-dose therapy list is gone, the per-medication compliance tiles are denser, and the assessment sits directly under the target. + +### Fixed + +- **The medical disclaimer appears once.** The "describes your own data, not a clinical assessment" line was repeated across many tiles; it now shows once as a single page footer (and no longer doubles up on the Coach page, which carries its own note). +- **Medication status counts meet contrast on the light theme.** The taken / skipped / missed figures now use the semantic colour tokens, which stay legible in both themes. + +### Documentation + +- **Provider setup is documented end to end.** The WHOOP guide now pins the exact OAuth scopes and the webhook URL format, and a new Google Health (Fitbit & Pixel) guide covers the Google Cloud OAuth client, scopes, and redirect URI. See `docs/integrations/`. + ## [1.12.5] — 2026-06-05 — correct sleep summary and readiness contributors ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7d8d0d3ce..6d24480a8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.12.5 + version: 1.12.6 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/docs/integrations/google-health.md b/docs/integrations/google-health.md new file mode 100644 index 000000000..98d96db88 --- /dev/null +++ b/docs/integrations/google-health.md @@ -0,0 +1,79 @@ +# Google Health integration (Fitbit & Pixel) + +> **Beta.** Field mapping is still being verified against live Google Health +> accounts, so some values may be missing or change in a future update. + +HealthLog reads Fitbit and Pixel Watch data through the **Google Health API**. +The OAuth app is therefore a **Google Cloud** OAuth client — not a Fitbit +developer-console app. Like WHOOP, credentials are brought per user: each user +registers their own Google Cloud OAuth client and pastes the client id/secret +into Settings. The internal integration key is `fitbit`. + +## 1. Create a Google Cloud OAuth client + +1. In create (or pick) a project and, under + **APIs & Services**, enable the **Google Health API**. +2. Configure the OAuth consent screen for the project. +3. Under **APIs & Services → Credentials**, create an **OAuth 2.0 Client ID** of + type **Web application**. +4. **Authorized redirect URI:** + `https://your-instance.example.com/api/fitbit/callback` +5. Save. Google issues a **Client ID** and **Client Secret** — keep the secret + out of any chat log. + +## 2. Scopes + +HealthLog requests exactly these read-only scopes: + +``` +https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly +https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly +https://www.googleapis.com/auth/googlehealth.sleep.readonly +https://www.googleapis.com/auth/googlehealth.profile.readonly +``` + +Location and intraday scopes are deliberately omitted to keep the +Restricted-scope review surface minimal. The authorize request sends +`access_type=offline` + `prompt=consent` — Google's requirement to issue a +refresh token (the equivalent of WHOOP's `offline` scope). + +## 3. Wire the credentials in Settings → Integrations + +Credentials are stored per user, not in `.env`. Sign in as the target user, open +**Settings → Integrations → Google Health**, and paste the client ID and secret. +They are encrypted AES-256-GCM at rest. + +## 4. Connect + +Click **Connect with Google Health**. HealthLog redirects to Google's OAuth +consent screen, exchanges the returned code for an access + refresh token, stores +them encrypted, and runs an initial sync. There is **no webhook** — Google Health +syncs on the safety-net cron pull. + +## Redirect URI is validated strictly + +The resolved `redirect_uri` is asserted at connect time to be absolute, `https` +(or `http` on localhost), to target exactly `/api/fitbit/callback`, and — when +`FITBIT_REDIRECT_URI` is set alongside `NEXT_PUBLIC_APP_URL` — to share its +origin. A misconfigured value fails closed rather than redirecting elsewhere. + +## Optional instance env var + +```env +# Only when the callback origin differs from NEXT_PUBLIC_APP_URL: +FITBIT_REDIRECT_URI="https://your-instance.example.com/api/fitbit/callback" +``` + +The Google Cloud client id/secret are per-user (Settings), not env vars. + +## Source overlap + +If Google Health and another source (Withings, WHOOP, Apple Health) both supply +the same vital — resting heart rate, blood oxygen, body temperature or +respiratory rate — you may see both values until a future update settles on a +single preferred source. + +## Disconnect + +The Settings **Disconnect** button revokes the stored tokens and removes the +connection. Previously synced measurements stay in the database. diff --git a/docs/integrations/whoop.md b/docs/integrations/whoop.md index 10a87f629..d9f9ce759 100644 --- a/docs/integrations/whoop.md +++ b/docs/integrations/whoop.md @@ -21,8 +21,16 @@ grant scoped to credentials they control. 2. **Redirect URI:** `https://your-instance.example.com/api/whoop/callback` (matches `WHOOP_REDIRECT_URI`, below). Add one entry per environment you connect from. -3. Request the scopes for recovery, cycle/strain, sleep, and the - workout/heart-rate reads the sync uses. +3. Request these scopes (the exact set HealthLog asks for): + + ``` + offline read:recovery read:sleep read:workout read:cycles read:profile read:body_measurement + ``` + + `offline` is **mandatory** — without it WHOOP issues no refresh token + and the connection dies when the first access token expires (~1 hour). + The remaining `read:*` scopes are read-only; request all of them so the + user grants once and every sync resource is covered. 4. Save the app. WHOOP issues a **Client ID** and **Client Secret** — keep the secret out of any chat log; it grants read access to the linked account's full WHOOP history. @@ -59,10 +67,22 @@ WHOOP_WEBHOOK_SECRET="$(openssl rand -hex 32)" - `WHOOP_REDIRECT_URI` — the OAuth callback URL handed to WHOOP. Must match a redirect URI registered in the WHOOP developer app. -- `WHOOP_WEBHOOK_SECRET` — the secret HealthLog verifies the WHOOP - webhook HMAC signature against. WHOOP signs each webhook body; the - handler validates the signature before processing. Generate a fresh - random value and restart the `app` container after setting it. +- `WHOOP_WEBHOOK_SECRET` — used twice: as the **trailing path segment** + of the webhook URL you register in the WHOOP console, and as the key + HealthLog verifies the WHOOP HMAC body signature against. Set the + webhook URL in the WHOOP console to: + + ``` + https://your-instance.example.com/api/whoop/webhook/ + ``` + + WHOOP signs each body (`X-WHOOP-Signature` + `X-WHOOP-Signature-Timestamp`, + HMAC-SHA256); the handler rejects deliveries older than five minutes and + validates the signature before processing. The body signing key on WHOOP's + side must equal `WHOOP_WEBHOOK_SECRET`. Generate a fresh random value and + restart the `app` container after setting it. Without a webhook, WHOOP + still syncs on the safety-net cron pull — only the near-real-time push is + lost. ## Disconnect diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index e3d02362e..96f1db1aa 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -152,6 +152,7 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build | Encryption-key rotation procedure | `docs/ops/encryption-key-rotation.md` | | Withings device sync | `docs/integrations/withings.md` | | WHOOP device sync (per-user BYO-keys) | `docs/integrations/whoop.md` | +| Google Health sync — Fitbit & Pixel (per-user BYO-keys) | `docs/integrations/google-health.md` | | Apple Health `export.zip` import | `docs/integrations/apple-health.md` | | AI provider setup (OpenAI / Anthropic / local / ChatGPT OAuth) | `docs/integrations/ai-providers.md` | diff --git a/messages/de.json b/messages/de.json index 73279398e..6f08908a0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1656,14 +1656,6 @@ "emptyAction": "Briefing erstellen", "loadingLabel": "Briefing wird zusammengestellt" }, - "therapyTimeline": { - "title": "GLP-1-Therapieverlauf", - "description": "Chronologische Liste: Dosis-Änderungen, Injektionen, Bestand und Nebenwirkungstage.", - "empty": "Keine GLP-1-Ereignisse in den letzten 90 Tagen.", - "doseTo": "Dosis auf", - "pens": "Pens", - "sideEffects": "Nebenwirkungen" - }, "trendsRow": { "title": "Trends", "subtitle": "Die letzten 30 Tage auf einen Blick", @@ -2002,12 +1994,8 @@ "medikamenteEmptyTitle": "Erstes Medikament hinzufügen", "medikamenteEmptyDescription": "Adhärenz-Insights brauchen mindestens ein erfasstes Medikament.", "medikamenteEmptyAction": "Medikamente öffnen", - "therapyTimelineDescription": "Chronologisches GLP-1-Therapieprotokoll — Dosis-Änderungen, Injektionen, Bestand, Nebenwirkungen.", "bmiDescription": "BMI aus deinem Gewicht und deiner Größe abgeleitet, mit Einschätzung.", "target": { - "range": "Ziel: {min}–{max} {unit}", - "bpRange": "Ziel: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}", - "inTargetShare": "{pct}% der erfassten Tage im Zielbereich", "adjustLink": "Zielbereich anpassen" }, "primary": { @@ -2586,20 +2574,19 @@ "bandVerySteady": "Deine Stimmung war sehr gleichmäßig", "bandSteady": "Deine Stimmung war recht gleichmäßig", "bandVariable": "Deine Stimmung war merklich schwankend", - "bandVeryVariable": "Deine Stimmung hat zuletzt stark geschwankt" + "bandVeryVariable": "Deine Stimmung hat zuletzt stark geschwankt", + "title": "Stabilität" }, "influence": { "title": "Einfluss auf die Stimmung", "detail": "Durchschnitt {withAvg} an den {withDays} Tagen mit diesem Tag, {withoutAvg} ohne.", "confidenceLow": "Geringe Sicherheit", "confidenceMedium": "Mittlere Sicherheit", - "confidenceHigh": "Hohe Sicherheit", - "disclaimer": "Das sind Zusammenhänge in deinen eigenen Daten, kein Beweis für eine Ursache. Muster, die einen Blick wert sind." + "confidenceHigh": "Hohe Sicherheit" }, "betterDays": { "title": "Was zu deinen besseren Tagen passt", - "description": "Tags und Werte, die mit deinen Tagen besserer und schlechterer Stimmung einhergehen, sortiert danach, wie stark sie sich zusammen bewegen.", - "disclaimer": "Nur Zusammenhänge, keine Ursachen. Sprich mit deiner Ärztin oder deinem Arzt, bevor du etwas änderst." + "description": "Tags und Werte, die mit deinen Tagen besserer und schlechterer Stimmung einhergehen, sortiert danach, wie stark sie sich zusammen bewegen." }, "crosstab": { "title": "Tags und deine Gesundheitswerte", @@ -2612,8 +2599,7 @@ "unitKcal": "kcal", "unitScore": "Pkt.", "detailSameDay": "Durchschnittlich {withAvg} {unit} an den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} {unit} ohne.", - "detailNextDay": "Durchschnittlich {withAvg} {unit} am Tag nach den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} {unit} sonst.", - "disclaimer": "Das sind Zusammenhänge in deinen eigenen Daten, kein Beweis für Ursachen. Muster, die einen Blick wert sind." + "detailNextDay": "Durchschnittlich {withAvg} {unit} am Tag nach den {withDays} Tagen mit diesem Tag gegenüber {withoutAvg} {unit} sonst." }, "discovery": { "title": "Was auffällt", @@ -2628,7 +2614,8 @@ "stat": "{n} gepaarte Tage · r = {r} · q = {q}", "statLabel": "Wie das berechnet wurde", "explainerLabel": "Wie das berechnet wurde" - } + }, + "inTargetTitle": "Im Zielbereich" }, "derived": { "coverage": { @@ -2785,6 +2772,7 @@ }, "scores": { "sectionTitle": "Wellness-Werte", + "loadError": "Deine Wellness-Werte konnten gerade nicht geladen werden.", "recovery": "Erholung", "stress": "Stress", "strain": "Belastung" @@ -2919,6 +2907,12 @@ "moodImproved": "Stimmung über 30 Tage leicht verbessert.", "moodDeclined": "Stimmung über 30 Tage leicht verschlechtert.", "moodStable": "Stimmung über die letzten 30 Tage stabil." + }, + "target": { + "heading": "Ziel" + }, + "disclaimer": { + "footer": "HealthLog beschreibt deine eigenen erfassten Daten — es ist keine klinische Bewertung und keine Diagnose. Sprich mit deiner Ärztin oder deinem Arzt über alles, was dich beunruhigt." } }, "thresholds": { diff --git a/messages/en.json b/messages/en.json index ff734fe34..13328d1ce 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1656,14 +1656,6 @@ "emptyAction": "Generate briefing", "loadingLabel": "Synthesising today's briefing" }, - "therapyTimeline": { - "title": "GLP-1 therapy timeline", - "description": "Chronological log of dose changes, injections, inventory events, and side-effect days.", - "empty": "No GLP-1 therapy events logged in the last 90 days.", - "doseTo": "dose set to", - "pens": "pens", - "sideEffects": "Side effects" - }, "trendsRow": { "title": "Trends", "subtitle": "Last 30 days at a glance", @@ -2002,12 +1994,8 @@ "medikamenteEmptyTitle": "Add your first medication", "medikamenteEmptyDescription": "Compliance insights need at least one tracked medication.", "medikamenteEmptyAction": "Open medications", - "therapyTimelineDescription": "Chronological GLP-1 therapy log — dose changes, injections, inventory, side effects.", "bmiDescription": "BMI derived from your weight and height, with a written assessment.", "target": { - "range": "Target: {min}–{max} {unit}", - "bpRange": "Target: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}", - "inTargetShare": "{pct}% of logged days within target", "adjustLink": "Adjust target range" }, "primary": { @@ -2586,20 +2574,19 @@ "bandVerySteady": "Your mood has been very steady", "bandSteady": "Your mood has been fairly steady", "bandVariable": "Your mood has been noticeably variable", - "bandVeryVariable": "Your mood has swung a lot lately" + "bandVeryVariable": "Your mood has swung a lot lately", + "title": "Stability" }, "influence": { "title": "Influence on mood", "detail": "Averages {withAvg} on the {withDays} days you tagged this vs {withoutAvg} without it.", "confidenceLow": "Low confidence", "confidenceMedium": "Medium confidence", - "confidenceHigh": "High confidence", - "disclaimer": "These are associations in your own data, not proof of cause. Patterns worth watching." + "confidenceHigh": "High confidence" }, "betterDays": { "title": "What goes with your better days", - "description": "Tags and metrics that line up with your higher- and lower-mood days, ranked by how strongly they move together.", - "disclaimer": "Associations only, not causes. Talk to your doctor before changing anything based on these." + "description": "Tags and metrics that line up with your higher- and lower-mood days, ranked by how strongly they move together." }, "crosstab": { "title": "Tags and your health metrics", @@ -2612,8 +2599,7 @@ "unitKcal": "kcal", "unitScore": "pts", "detailSameDay": "Averages {withAvg} {unit} on the {withDays} days you tagged this vs {withoutAvg} {unit} without it.", - "detailNextDay": "Averages {withAvg} {unit} the day after the {withDays} days you tagged this vs {withoutAvg} {unit} otherwise.", - "disclaimer": "These are associations in your own data, not proof of cause. Patterns worth watching." + "detailNextDay": "Averages {withAvg} {unit} the day after the {withDays} days you tagged this vs {withoutAvg} {unit} otherwise." }, "discovery": { "title": "What stands out", @@ -2628,7 +2614,8 @@ "stat": "{n} paired days · r = {r} · q = {q}", "statLabel": "How this was computed", "explainerLabel": "How this was computed" - } + }, + "inTargetTitle": "In your range" }, "derived": { "coverage": { @@ -2785,6 +2772,7 @@ }, "scores": { "sectionTitle": "Wellness scores", + "loadError": "Could not load your wellness scores right now.", "recovery": "Recovery", "stress": "Stress", "strain": "Strain" @@ -2919,6 +2907,12 @@ "moodImproved": "Mood trended slightly higher over 30 days.", "moodDeclined": "Mood trended slightly lower over 30 days.", "moodStable": "Mood held steady over the last 30 days." + }, + "target": { + "heading": "Target" + }, + "disclaimer": { + "footer": "HealthLog describes your own tracked data — it is not a clinical assessment or diagnosis. Talk to your doctor about anything that concerns you." } }, "thresholds": { diff --git a/messages/es.json b/messages/es.json index 4d6f06a78..68ab74986 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1656,14 +1656,6 @@ "emptyAction": "Generar resumen", "loadingLabel": "Preparando el resumen" }, - "therapyTimeline": { - "title": "Historial de terapia GLP-1", - "description": "Lista cronológica: cambios de dosis, inyecciones, existencias y días con efectos secundarios.", - "empty": "Sin eventos GLP-1 en los últimos 90 días.", - "doseTo": "Dosis a", - "pens": "Plumas", - "sideEffects": "Efectos secundarios" - }, "trendsRow": { "title": "Tendencias", "subtitle": "Los últimos 30 días de un vistazo", @@ -2002,12 +1994,8 @@ "medikamenteEmptyTitle": "Añadir primer medicamento", "medikamenteEmptyDescription": "Los insights de adherencia necesitan al menos un medicamento registrado.", "medikamenteEmptyAction": "Abrir medicamentos", - "therapyTimelineDescription": "Protocolo cronológico de terapia GLP-1 — cambios de dosis, inyecciones, existencias, efectos secundarios.", "bmiDescription": "IMC derivado de tu peso y altura, con valoración.", "target": { - "range": "Objetivo: {min}–{max} {unit}", - "bpRange": "Objetivo: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}", - "inTargetShare": "{pct}% de los días registrados dentro del objetivo", "adjustLink": "Ajustar el rango objetivo" }, "primary": { @@ -2586,20 +2574,19 @@ "bandVerySteady": "Tu estado de ánimo ha sido muy estable", "bandSteady": "Tu estado de ánimo ha sido bastante estable", "bandVariable": "Tu estado de ánimo ha sido notablemente variable", - "bandVeryVariable": "Tu estado de ánimo ha fluctuado mucho últimamente" + "bandVeryVariable": "Tu estado de ánimo ha fluctuado mucho últimamente", + "title": "Estabilidad" }, "influence": { "title": "Influencia en el estado de ánimo", "detail": "Promedio de {withAvg} en los {withDays} días que lo etiquetaste, frente a {withoutAvg} sin él.", "confidenceLow": "Confianza baja", "confidenceMedium": "Confianza media", - "confidenceHigh": "Confianza alta", - "disclaimer": "Son asociaciones en tus propios datos, no una prueba de causa. Patrones que vale la pena observar." + "confidenceHigh": "Confianza alta" }, "betterDays": { "title": "Qué acompaña a tus mejores días", - "description": "Etiquetas y métricas que coinciden con tus días de mejor y peor ánimo, ordenadas por la fuerza con que se mueven juntas.", - "disclaimer": "Solo asociaciones, no causas. Consulta a tu médico antes de cambiar nada por esto." + "description": "Etiquetas y métricas que coinciden con tus días de mejor y peor ánimo, ordenadas por la fuerza con que se mueven juntas." }, "crosstab": { "title": "Etiquetas y tus métricas de salud", @@ -2612,8 +2599,7 @@ "unitKcal": "kcal", "unitScore": "pts", "detailSameDay": "Promedia {withAvg} {unit} en los {withDays} días que lo etiquetaste frente a {withoutAvg} {unit} sin él.", - "detailNextDay": "Promedia {withAvg} {unit} el día después de los {withDays} días que lo etiquetaste frente a {withoutAvg} {unit} el resto.", - "disclaimer": "Son asociaciones en tus propios datos, no prueba de causa. Patrones que vale la pena observar." + "detailNextDay": "Promedia {withAvg} {unit} el día después de los {withDays} días que lo etiquetaste frente a {withoutAvg} {unit} el resto." }, "discovery": { "title": "Lo que destaca", @@ -2628,7 +2614,8 @@ "stat": "{n} días emparejados · r = {r} · q = {q}", "statLabel": "Cómo se calculó esto", "explainerLabel": "Cómo se calculó esto" - } + }, + "inTargetTitle": "En tu rango" }, "derived": { "coverage": { @@ -2785,6 +2772,7 @@ }, "scores": { "sectionTitle": "Puntuaciones de bienestar", + "loadError": "No se pudieron cargar tus puntuaciones de bienestar en este momento.", "recovery": "Recuperación", "stress": "Estrés", "strain": "Esfuerzo" @@ -2919,6 +2907,12 @@ "moodImproved": "El estado de ánimo mejoró ligeramente en 30 días.", "moodDeclined": "El estado de ánimo bajó ligeramente en 30 días.", "moodStable": "El estado de ánimo se mantuvo estable en los últimos 30 días." + }, + "target": { + "heading": "Objetivo" + }, + "disclaimer": { + "footer": "HealthLog describe tus propios datos registrados — no es una evaluación clínica ni un diagnóstico. Habla con tu médico sobre cualquier cosa que te preocupe." } }, "thresholds": { diff --git a/messages/fr.json b/messages/fr.json index 13c542728..ab478edbd 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1656,14 +1656,6 @@ "emptyAction": "Générer le briefing", "loadingLabel": "Préparation du briefing" }, - "therapyTimeline": { - "title": "Historique du traitement GLP-1", - "description": "Liste chronologique : changements de dose, injections, stock et jours d’effets secondaires.", - "empty": "Aucun événement GLP-1 sur les 90 derniers jours.", - "doseTo": "Dose à", - "pens": "Stylos", - "sideEffects": "Effets secondaires" - }, "trendsRow": { "title": "Tendances", "subtitle": "Les 30 derniers jours en un coup d’œil", @@ -2002,12 +1994,8 @@ "medikamenteEmptyTitle": "Ajouter le premier médicament", "medikamenteEmptyDescription": "Les insights d’observance ont besoin d’au moins un médicament enregistré.", "medikamenteEmptyAction": "Ouvrir les médicaments", - "therapyTimelineDescription": "Journal chronologique de la thérapie GLP-1 — changements de dose, injections, stock, effets secondaires.", "bmiDescription": "IMC dérivé de ton poids et de ta taille, avec évaluation.", "target": { - "range": "Cible : {min}–{max} {unit}", - "bpRange": "Cible : {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}", - "inTargetShare": "{pct}% des jours enregistrés dans la cible", "adjustLink": "Ajuster la plage cible" }, "primary": { @@ -2586,20 +2574,19 @@ "bandVerySteady": "Votre humeur a été très stable", "bandSteady": "Votre humeur a été plutôt stable", "bandVariable": "Votre humeur a été nettement variable", - "bandVeryVariable": "Votre humeur a beaucoup fluctué récemment" + "bandVeryVariable": "Votre humeur a beaucoup fluctué récemment", + "title": "Stabilité" }, "influence": { "title": "Influence sur l'humeur", "detail": "Moyenne de {withAvg} les {withDays} jours où vous l'avez indiqué, contre {withoutAvg} sans.", "confidenceLow": "Confiance faible", "confidenceMedium": "Confiance moyenne", - "confidenceHigh": "Confiance élevée", - "disclaimer": "Ce sont des associations dans vos propres données, pas une preuve de cause. Des tendances à surveiller." + "confidenceHigh": "Confiance élevée" }, "betterDays": { "title": "Ce qui accompagne vos meilleurs jours", - "description": "Les tags et mesures qui coïncident avec vos jours de meilleure et de moins bonne humeur, classés selon la force du lien.", - "disclaimer": "Des associations seulement, pas des causes. Parlez-en à votre médecin avant de changer quoi que ce soit." + "description": "Les tags et mesures qui coïncident avec vos jours de meilleure et de moins bonne humeur, classés selon la force du lien." }, "crosstab": { "title": "Tags et vos métriques de santé", @@ -2612,8 +2599,7 @@ "unitKcal": "kcal", "unitScore": "pts", "detailSameDay": "En moyenne {withAvg} {unit} les {withDays} jours avec ce tag contre {withoutAvg} {unit} sans.", - "detailNextDay": "En moyenne {withAvg} {unit} le lendemain des {withDays} jours avec ce tag contre {withoutAvg} {unit} sinon.", - "disclaimer": "Ce sont des associations dans vos propres données, pas une preuve de cause. Des tendances à surveiller." + "detailNextDay": "En moyenne {withAvg} {unit} le lendemain des {withDays} jours avec ce tag contre {withoutAvg} {unit} sinon." }, "discovery": { "title": "Ce qui ressort", @@ -2628,7 +2614,8 @@ "stat": "{n} jours appariés · r = {r} · q = {q}", "statLabel": "Comment ceci est calculé", "explainerLabel": "Comment ceci est calculé" - } + }, + "inTargetTitle": "Dans votre plage" }, "derived": { "coverage": { @@ -2785,6 +2772,7 @@ }, "scores": { "sectionTitle": "Scores de bien-être", + "loadError": "Impossible de charger vos scores de bien-être pour le moment.", "recovery": "Récupération", "stress": "Stress", "strain": "Effort" @@ -2919,6 +2907,12 @@ "moodImproved": "Humeur en légère hausse sur 30 jours.", "moodDeclined": "Humeur en légère baisse sur 30 jours.", "moodStable": "Humeur stable sur les 30 derniers jours." + }, + "target": { + "heading": "Objectif" + }, + "disclaimer": { + "footer": "HealthLog décrit vos propres données suivies — il ne s'agit pas d'une évaluation clinique ni d'un diagnostic. Parlez à votre médecin de tout ce qui vous préoccupe." } }, "thresholds": { diff --git a/messages/it.json b/messages/it.json index 5245295ef..c4a4248a5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1656,14 +1656,6 @@ "emptyAction": "Genera briefing", "loadingLabel": "Preparazione del briefing" }, - "therapyTimeline": { - "title": "Cronologia terapia GLP-1", - "description": "Elenco cronologico: cambi di dose, iniezioni, scorte e giorni con effetti collaterali.", - "empty": "Nessun evento GLP-1 negli ultimi 90 giorni.", - "doseTo": "Dose a", - "pens": "Penne", - "sideEffects": "Effetti collaterali" - }, "trendsRow": { "title": "Tendenze", "subtitle": "Gli ultimi 30 giorni a colpo d’occhio", @@ -2002,12 +1994,8 @@ "medikamenteEmptyTitle": "Aggiungi il primo farmaco", "medikamenteEmptyDescription": "Gli insights di aderenza richiedono almeno un farmaco registrato.", "medikamenteEmptyAction": "Apri farmaci", - "therapyTimelineDescription": "Protocollo cronologico della terapia GLP-1 — cambi di dose, iniezioni, scorte, effetti collaterali.", "bmiDescription": "BMI derivato dal tuo peso e dalla tua altezza, con valutazione.", "target": { - "range": "Obiettivo: {min}–{max} {unit}", - "bpRange": "Obiettivo: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}", - "inTargetShare": "{pct}% dei giorni registrati nell'obiettivo", "adjustLink": "Modifica l'intervallo obiettivo" }, "primary": { @@ -2586,20 +2574,19 @@ "bandVerySteady": "Il tuo umore è stato molto stabile", "bandSteady": "Il tuo umore è stato abbastanza stabile", "bandVariable": "Il tuo umore è stato notevolmente variabile", - "bandVeryVariable": "Il tuo umore ha oscillato molto di recente" + "bandVeryVariable": "Il tuo umore ha oscillato molto di recente", + "title": "Stabilità" }, "influence": { "title": "Influenza sull'umore", "detail": "Media di {withAvg} nei {withDays} giorni in cui l'hai indicato, contro {withoutAvg} senza.", "confidenceLow": "Affidabilità bassa", "confidenceMedium": "Affidabilità media", - "confidenceHigh": "Affidabilità alta", - "disclaimer": "Sono associazioni nei tuoi dati, non una prova di causa. Schemi da tenere d'occhio." + "confidenceHigh": "Affidabilità alta" }, "betterDays": { "title": "Cosa accompagna le tue giornate migliori", - "description": "Tag e parametri che coincidono con le tue giornate di umore migliore e peggiore, ordinati per quanto si muovono insieme.", - "disclaimer": "Solo associazioni, non cause. Parla con il tuo medico prima di cambiare qualcosa." + "description": "Tag e parametri che coincidono con le tue giornate di umore migliore e peggiore, ordinati per quanto si muovono insieme." }, "crosstab": { "title": "Tag e le tue metriche di salute", @@ -2612,8 +2599,7 @@ "unitKcal": "kcal", "unitScore": "pt", "detailSameDay": "In media {withAvg} {unit} nei {withDays} giorni con questo tag contro {withoutAvg} {unit} senza.", - "detailNextDay": "In media {withAvg} {unit} il giorno dopo i {withDays} giorni con questo tag contro {withoutAvg} {unit} altrimenti.", - "disclaimer": "Sono associazioni nei tuoi dati, non prova di causa. Schemi che vale la pena osservare." + "detailNextDay": "In media {withAvg} {unit} il giorno dopo i {withDays} giorni con questo tag contro {withoutAvg} {unit} altrimenti." }, "discovery": { "title": "Cosa risalta", @@ -2628,7 +2614,8 @@ "stat": "{n} giorni appaiati · r = {r} · q = {q}", "statLabel": "Come è stato calcolato", "explainerLabel": "Come è stato calcolato" - } + }, + "inTargetTitle": "Nel tuo intervallo" }, "derived": { "coverage": { @@ -2785,6 +2772,7 @@ }, "scores": { "sectionTitle": "Punteggi di benessere", + "loadError": "Al momento non è stato possibile caricare i tuoi punteggi di benessere.", "recovery": "Recupero", "stress": "Stress", "strain": "Sforzo" @@ -2919,6 +2907,12 @@ "moodImproved": "Umore in lieve miglioramento su 30 giorni.", "moodDeclined": "Umore in lieve calo su 30 giorni.", "moodStable": "Umore stabile negli ultimi 30 giorni." + }, + "target": { + "heading": "Obiettivo" + }, + "disclaimer": { + "footer": "HealthLog descrive i tuoi dati registrati — non è una valutazione clinica né una diagnosi. Parla con il tuo medico di qualsiasi cosa ti preoccupi." } }, "thresholds": { diff --git a/messages/pl.json b/messages/pl.json index d4b5efcff..312778ce2 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1656,14 +1656,6 @@ "emptyAction": "Wygeneruj briefing", "loadingLabel": "Przygotowanie briefingu" }, - "therapyTimeline": { - "title": "Historia terapii GLP-1", - "description": "Chronologiczna lista: zmiany dawki, iniekcje, zapasy i dni z działaniami niepożądanymi.", - "empty": "Brak zdarzeń GLP-1 w ostatnich 90 dniach.", - "doseTo": "Dawka na", - "pens": "Peny", - "sideEffects": "Działania niepożądane" - }, "trendsRow": { "title": "Trendy", "subtitle": "Ostatnie 30 dni w skrócie", @@ -2002,12 +1994,8 @@ "medikamenteEmptyTitle": "Dodaj pierwszy lek", "medikamenteEmptyDescription": "Insights przestrzegania wymagają przynajmniej jednego zarejestrowanego leku.", "medikamenteEmptyAction": "Otwórz leki", - "therapyTimelineDescription": "Chronologiczny protokół terapii GLP-1 — zmiany dawki, iniekcje, stan, działania niepożądane.", "bmiDescription": "BMI obliczone z twojej wagi i wzrostu, z oceną.", "target": { - "range": "Cel: {min}–{max} {unit}", - "bpRange": "Cel: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}", - "inTargetShare": "{pct}% zarejestrowanych dni w zakresie celu", "adjustLink": "Dostosuj zakres docelowy" }, "primary": { @@ -2586,20 +2574,19 @@ "bandVerySteady": "Twój nastrój był bardzo stabilny", "bandSteady": "Twój nastrój był dość stabilny", "bandVariable": "Twój nastrój był zauważalnie zmienny", - "bandVeryVariable": "Twój nastrój ostatnio mocno się wahał" + "bandVeryVariable": "Twój nastrój ostatnio mocno się wahał", + "title": "Stabilność" }, "influence": { "title": "Wpływ na nastrój", "detail": "Średnia {withAvg} w {withDays} dniach z tym tagiem wobec {withoutAvg} bez niego.", "confidenceLow": "Niska pewność", "confidenceMedium": "Średnia pewność", - "confidenceHigh": "Wysoka pewność", - "disclaimer": "To powiązania w Twoich danych, a nie dowód przyczyny. Wzorce warte obserwacji." + "confidenceHigh": "Wysoka pewność" }, "betterDays": { "title": "Co towarzyszy Twoim lepszym dniom", - "description": "Tagi i wskaźniki, które pokrywają się z dniami lepszego i gorszego nastroju, uszeregowane według siły powiązania.", - "disclaimer": "Tylko powiązania, nie przyczyny. Skonsultuj się z lekarzem, zanim coś zmienisz." + "description": "Tagi i wskaźniki, które pokrywają się z dniami lepszego i gorszego nastroju, uszeregowane według siły powiązania." }, "crosstab": { "title": "Tagi a Twoje wskaźniki zdrowia", @@ -2612,8 +2599,7 @@ "unitKcal": "kcal", "unitScore": "pkt", "detailSameDay": "Średnio {withAvg} {unit} w {withDays} dniach z tym tagiem wobec {withoutAvg} {unit} bez niego.", - "detailNextDay": "Średnio {withAvg} {unit} dzień po {withDays} dniach z tym tagiem wobec {withoutAvg} {unit} w pozostałe dni.", - "disclaimer": "To powiązania w Twoich danych, nie dowód przyczyny. Wzorce warte obserwacji." + "detailNextDay": "Średnio {withAvg} {unit} dzień po {withDays} dniach z tym tagiem wobec {withoutAvg} {unit} w pozostałe dni." }, "discovery": { "title": "Co się wyróżnia", @@ -2628,7 +2614,8 @@ "stat": "{n} sparowanych dni · r = {r} · q = {q}", "statLabel": "Jak to obliczono", "explainerLabel": "Jak to obliczono" - } + }, + "inTargetTitle": "W Twoim zakresie" }, "derived": { "coverage": { @@ -2785,6 +2772,7 @@ }, "scores": { "sectionTitle": "Wyniki dobrostanu", + "loadError": "Nie udało się teraz wczytać Twoich wyników dobrostanu.", "recovery": "Regeneracja", "stress": "Stres", "strain": "Obciążenie" @@ -2919,6 +2907,12 @@ "moodImproved": "Nastrój nieco się poprawił w ciągu 30 dni.", "moodDeclined": "Nastrój nieco się pogorszył w ciągu 30 dni.", "moodStable": "Nastrój pozostał stabilny przez ostatnie 30 dni." + }, + "target": { + "heading": "Cel" + }, + "disclaimer": { + "footer": "HealthLog opisuje Twoje własne zarejestrowane dane — nie jest oceną kliniczną ani diagnozą. Porozmawiaj z lekarzem o wszystkim, co Cię niepokoi." } }, "thresholds": { diff --git a/package.json b/package.json index fb0f2062c..e34f940bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.12.5", + "version": "1.12.6", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", diff --git a/src/app/insights/__tests__/assessment-spine-order.test.ts b/src/app/insights/__tests__/assessment-spine-order.test.ts index d73973512..acde8bac2 100644 --- a/src/app/insights/__tests__/assessment-spine-order.test.ts +++ b/src/app/insights/__tests__/assessment-spine-order.test.ts @@ -5,10 +5,13 @@ import { join } from "node:path"; /** * v1.12.4 — canonical metric-detail spine guard. * - * The unified subpage spine is: intro → chart → target card → assessment → - * stat strip. The assessment is the LAST content block the page hands to - * `` as `children`; the shell appends the Min/Max/Median/Mean - * stat strip below it. The generic `HealthKitMetricPage` scaffold enforces + * v1.12.6 — the unified subpage spine is: intro → stat strip → chart → + * target card → assessment. The stat strip moved ABOVE the chart (the + * shell renders the `statStrip` prop before `children`); the assessment + * stays the LAST content block the page hands to `` as + * `children`. This source-order guard is unaffected by the render-position + * move because `statStrip` is a prop on the opening tag and the assessment + * is still the final child element. The generic `HealthKitMetricPage` scaffold enforces * this for the ~29 HealthKit pages structurally; the bespoke pages each * hand-write their body, so a future edit can silently slot a card after the * assessment (as `mood` and `medications` did before). diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index 875cdb12b..e6369be8e 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { HeartPulse } from "lucide-react"; +import { ArrowDownRight, ArrowUpRight, HeartPulse } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; @@ -112,12 +112,14 @@ export default function InsightsBlutdruckPage() { unit="mmHg" fractionDigits={0} seriesLabel={t("charts.systolic")} + icon={ArrowUpRight} /> } diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index 5c5e3227e..fd74c703b 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -10,7 +10,7 @@ import { queryKeys } from "@/lib/query-keys"; import { useTranslations } from "@/lib/i18n/context"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; import { Progress } from "@/components/ui/progress"; import { ComplianceHeatmap } from "@/components/charts/compliance-heatmap"; @@ -18,8 +18,7 @@ import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; import { InsightStatusCard } from "@/components/insights/insight-status-card"; import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; -import { TherapyTimeline } from "@/components/insights/therapy-timeline"; -import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader"; +import { TileHeader } from "@/components/insights/tile-header"; /** * v1.4.25 W4 — `/insights/medications`. @@ -203,33 +202,37 @@ export default function InsightsMedikamentePage() { > {medications.map((med) => { const medicationSummary = medicationSummaryById.get(med.id); - // UI-H3 — route every medication-list row (medications page - // + insights/medications) through `` - // so the surface stays one shape: `{name} {dose}` on line 1 - // + outline category badge on line 2. The streak chip rides - // the `stateBadges` slot so a strong streak still surfaces. + // v1.12.6 — the per-medication compliance card harmonises with + // the rest of the Insights tiles: its header runs through the + // canonical `` (Pill glyph + med name as the white + // heading), and the dose + category + streak fold into one + // compact muted line below it. Every datum the previous, taller + // card carried (7d/30d bars, taken/skipped/missed, heatmap, + // per-med assistant sentence) stays — only the vertical rhythm + // tightens. const categoryLabel = med.category === "BLOOD_PRESSURE" ? t("medications.categoryBloodPressure") : med.category === "VITAMIN" ? t("medications.categoryVitamin") : t("medications.categoryOther"); - const streakBadge = - med.streak > 0 ? ( - - {t("insights.dayStreak", { count: med.streak })} - - ) : null; return ( - - - -
+ + + +
+ {med.dose} + + {categoryLabel} + {med.streak > 0 ? ( + + {t("insights.dayStreak", { count: med.streak })} + + ) : null} +
+
+ +
{t("insights.compliance7d")} {med.compliance7}% @@ -238,34 +241,32 @@ export default function InsightsMedikamentePage() { accessible name (Lighthouse a11y 91/94). */}
-
+
{t("insights.compliance30d")} {med.compliance30}%
- {med.taken7}{" "} + {med.taken7}{" "} {t("insights.taken")} - - {med.skipped7} - {" "} + {med.skipped7}{" "} {t("insights.skipped")} - {med.missed7}{" "} + {med.missed7}{" "} {t("insights.missed")}
@@ -283,11 +284,6 @@ export default function InsightsMedikamentePage() { - {/* v1.4.25 W4d — GLP-1 therapy timeline. Self-hides for users - without an active GLP-1 medication, so the page collapses - back to the compliance grid for everyone else. */} - - {/* v1.12.2 — the assessment is the LAST block on every bespoke metric-detail page, matching the canonical spine the generic scaffold renders. This card reads the medication-compliance diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index a98cfe275..958792a4d 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -18,6 +18,7 @@ import { HeroStrip } from "@/components/insights/hero-strip"; import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; +import { useDashboardDerived } from "@/components/insights/derived/use-dashboard-derived"; // v1.4.41 W-ORG — shared shape lives in `src/types/analytics.ts` as // `InsightsAnalyticsData`; aliased back to the local name to keep the // rest of this file readable. @@ -78,6 +79,20 @@ const TrendsRow = dynamic( loading: () => , }, ); +// v1.12.6 — the wellness-score strip, lifted OUT of the vitals dashboard so +// it renders as its own full-width section directly above the daily briefing. +// Both this strip and the vitals grid read the ONE shared derived batch the +// page owns (passed in via `batch`), so the lift adds no second request. +const WellnessScores = dynamic( + () => + import("@/components/insights/derived").then((mod) => ({ + default: mod.WellnessScores, + })), + { + ssr: false, + loading: () => , + }, +); // v1.10.0 — the Vitals dashboard (Apple-Health-Highlights grid of // personal-typical-range + passthrough-reframe tiles). Deferred behind // `next/dynamic` like the other below-the-fold blocks; each tile owns its @@ -224,6 +239,11 @@ export default function InsightsPage() { const analyticsQuery = useAnalyticsQuery(); const analytics = analyticsQuery.data as AnalyticsData | undefined; + // v1.12.6 — the page owns the ONE overview derived batch. The wellness + // strip (above the briefing) and the vitals grid (below it) both read this + // single query instance, so the wellness lift adds no second request. + const dashboardDerived = useDashboardDerived(isAuthenticated); + // Empty-state shortcut — only paint once the comprehensive query has // resolved AND reported zero measurements. While it's in-flight we // fall through to the streamed shell so the user gets the hero + @@ -265,22 +285,25 @@ export default function InsightsPage() { // server children would require a Server-Component refactor; that's // a v1.5.x track. - // v1.12.4 — overview section order, top → bottom: - // 1. "Guten Morgen" Hero + Coach questions (HeroStrip) + the detailed - // "Heute auf einen Blick" briefing right below it (same briefing - // cluster, stays at the top). - // 2. Wellnesswerte ┐ both live inside , which renders - // 3. Vitalwerte ┘ the wellness-score strip first, then the vitals - // grid — so a single mount already gives the 2 → 3 sequence. - // 4. Trends (TrendsRow). - // 5. "Dein Zeitraum im Rückblick" retrospective (PeriodNarrativeCard). - // 6. "Signale des Tages" — today's signal (CoincidentDeviationCard) plus + // v1.12.6 — overview section order, top → bottom: + // 1. "Guten Morgen" Hero + Coach questions (HeroStrip). + // 2. Wellnesswerte — the wellness-score strip (lifted OUT of + // so it sits ABOVE the briefing as its own + // full-width section). + // 3. "Heute auf einen Blick" briefing (DailyBriefing). + // 4. Vitalwerte — the vitals grid (+ mobility) inside , + // which no longer renders the wellness strip. + // 5. Trends (TrendsRow). + // 6. "Dein Zeitraum im Rückblick" retrospective (PeriodNarrativeCard). + // 7. "Signale des Tages" — today's signal (CoincidentDeviationCard) plus // the rhythm-events alert timeline, kept out of the AI gate so a // health alert is never hidden. - // The per-metric correlation cards moved onto the metric pages, so the - // overview renders no correlation row. The duplicate footer "prepare - // assessments" control was removed in v1.12.4 — the tab-strip regenerate - // button is the single affordance. + // The wellness strip and the vitals grid share the ONE `dashboardDerived` + // batch the page owns — one request, two sections. The per-metric + // correlation cards moved onto the metric pages, so the overview renders no + // correlation row. The duplicate footer "prepare assessments" control was + // removed in v1.12.4 — the tab-strip regenerate button is the single + // affordance. The generic disclaimer lives once in the layout-shell footer. return (
+ + {flags.briefing && ( )} - + } + statStrip={ + + } diversityNudge={ } + statStrip={ + + } diversityNudge={ ` unit tests. * - * The panel surfaces the numeric target range, the verbal status pill, - * the guideline source, the in-target share, the 30-day average, the - * range bar and the 7-day consistency strip on each insights category - * page, reading the same `/api/insights/targets` payload that powers - * `/targets`. The component depends on `useAuth` (gate) and TanStack - * Query (`useQuery`) for the payload, so both are mocked here and the - * assertions run through SSR — the load-bearing behaviour is the - * range/share/source rendering + the slug→type mapping (including the - * blood-glucose per-context split + unit conversion), not the live - * fetch. + * The panel surfaces the canonical "Target" tile header, the verbal + * status pill, the guideline source, the 30-day average (BP only), the + * range bar (whose axis labels carry the band endpoints) and the 7-day + * consistency strip on each insights category page, reading the same + * `/api/insights/targets` payload that powers `/targets`. The component + * depends on `useAuth` (gate) and TanStack Query (`useQuery`) for the + * payload, so both are mocked here and the assertions run through SSR — + * the load-bearing behaviour is the header/bar/source rendering + the + * slug→type mapping (including the blood-glucose per-context split + unit + * conversion), not the live fetch. */ vi.mock("@/hooks/use-auth", () => ({ @@ -154,10 +154,17 @@ const GLUCOSE_DATA: TargetsFixture = { }; describe("", () => { - it("renders the numeric range for a simple metric", () => { + it("renders the canonical Target tile header and the band endpoints on the bar", () => { const html = renderWith("weight", WEIGHT_DATA); - // Trailing `.0` trimmed, single-decimal kept. - expect(html).toContain("Target: 60.1–80.9 kg"); + // v1.12.6 — the heading is the single-word canonical ``; + // the band numbers live on the range bar's axis labels rather than a + // redundant "Target: 60.1–80.9 kg" string above it. + expect(html).toContain('data-slot="tile-header"'); + expect(html).toContain("Target"); + expect(html).not.toContain("Target: 60.1–80.9 kg"); + // The min / max endpoints surface as the range-bar axis labels. + expect(html).toContain("60.1 kg"); + expect(html).toContain("80.9 kg"); // 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 @@ -188,9 +195,13 @@ describe("", () => { expect(html).not.toContain("30-day average: 71.4 kg"); }); - it("stitches the systolic + diastolic bands for blood pressure", () => { + it("renders the systolic + diastolic bands for blood pressure", () => { const html = renderWith("blood-pressure", BP_DATA); - expect(html).toContain("Target: 120–129 / 70–79 mmHg"); + // v1.12.6 — the stitched "Target: 120–129 / 70–79 mmHg" string is gone; + // the canonical "Target" header leads and each band's endpoints surface + // on its own range bar. + expect(html).toContain('data-slot="tile-header"'); + expect(html).not.toContain("Target: 120–129 / 70–79 mmHg"); // 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"); @@ -223,7 +234,9 @@ describe("", () => { ...WEIGHT_DATA, targets: [{ ...WEIGHT_ITEM, insufficientData: true }], }); - expect(html).toContain("Target: 60.1–80.9 kg"); + // v1.12.6 — the canonical Target header still renders; the share + + // consistency strip suppress on insufficient data. + expect(html).toContain('data-slot="tile-header"'); expect(html).not.toContain("of logged days within target"); expect(html).not.toContain('data-slot="consistency-strip"'); }); @@ -253,9 +266,12 @@ describe("", () => { html.match(/data-slot="metric-target-summary"/g) ?? [] ).length; expect(panelCount).toBe(2); + // v1.12.6 — the per-context band numbers live on each panel's range + // bar (the redundant "Target: 70–100 mg/dL" string was removed). The // mg/dL display unit keeps the canonical values unchanged. - expect(html).toContain("Target: 70–100 mg/dL"); - expect(html).toContain("Target: 70–140 mg/dL"); + expect(html).toContain("70 mg/dL"); + expect(html).toContain("100 mg/dL"); + expect(html).toContain("140 mg/dL"); // ADA / DDG source surfaces for each panel. expect(html).toContain("Source: ADA 2024 / DDG"); // The i18n label keys resolve to EN context headings (rendered @@ -270,10 +286,12 @@ describe("", () => { profile: { glucoseUnit: "mmol/L" }, }); // 70 mg/dL → 3.9, 100 mg/dL → 5.5, 140 mg/dL → 7.8 mmol/L - // (the /targets converter rounds to one decimal). - expect(html).toContain("Target: 3.9–5.5 mmol/L"); - expect(html).toContain("Target: 3.9–7.8 mmol/L"); - expect(html).toContain("mmol/L"); + // (the /targets converter rounds to one decimal). v1.12.6 — the + // converted endpoints surface on the range bar, not a "Target: …" + // string. + expect(html).toContain("3.9 mmol/L"); + expect(html).toContain("5.5 mmol/L"); + expect(html).toContain("7.8 mmol/L"); }); it("renders nothing when no glucose context has a band", () => { diff --git a/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx b/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx index 26969df02..5b20d2efd 100644 --- a/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx +++ b/src/components/insights/derived/__tests__/vitals-dashboard.test.tsx @@ -6,18 +6,7 @@ import type { DerivedMetricResponse, DerivedBatchToken, } from "../use-derived-metric"; - -// The dashboard now reads the whole grid through ONE batched query. Drive it -// by stubbing `useDerivedBatch` and returning a `read(token)` selector that -// resolves each (metric, type) pair from a scenario map. -const useDerivedBatch = vi.fn(); -vi.mock("../use-derived-metric", () => ({ - useDerivedBatch: (...a: unknown[]) => useDerivedBatch(...a), -})); -// The auth hook gates the (mocked-away) query — stub it authenticated. -vi.mock("@/hooks/use-auth", () => ({ - useAuth: () => ({ isAuthenticated: true }), -})); +import type { DashboardDerived } from "../use-dashboard-derived"; import { VitalsDashboard } from "../vitals-dashboard"; @@ -58,33 +47,36 @@ function insufficient(reason: string, historyDays = 0): Resp { } /** - * Install a batch mock that resolves each requested token from `resolve`, - * defaulting unmatched tokens to the absent state so a scenario only has to - * name the tiles it cares about. + * Build a shared-batch handle that resolves each requested token from + * `resolve`, defaulting unmatched tokens to the absent state so a scenario + * only has to name the tiles it cares about. The page owns this batch in + * production; the dashboard now receives it via the `batch` prop, so the test + * passes a stub handle directly instead of mocking the query hook. */ function mockBatch( resolve: (token: DerivedBatchToken) => Resp, extra: { isLoading?: boolean; isError?: boolean; refetch?: () => void } = {}, -) { - useDerivedBatch.mockReturnValue({ +): DashboardDerived { + return { isLoading: extra.isLoading ?? false, isError: extra.isError ?? false, refetch: extra.refetch ?? (() => {}), - read: (token: DerivedBatchToken) => resolve(token) as unknown as DerivedMetricResponse, - }); + read: (token: DerivedBatchToken) => + resolve(token) as unknown as DerivedMetricResponse, + } as unknown as DashboardDerived; } beforeEach(() => vi.clearAllMocks()); describe("", () => { it("renders an available baseline vital as an ok tile", () => { - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "VITALS_BASELINE" && token.type === "RESTING_HEART_RATE") { return ok({ type: "RESTING_HEART_RATE", center: 55, low: 48, high: 62, spread: 7, sampleDays: 30, k: 3 }); } return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(html).toContain('data-slot="vitals-dashboard"'); expect(html).toContain('data-metric="RESTING_HEART_RATE"'); expect(html).toContain('data-state="ok"'); @@ -93,8 +85,8 @@ describe("", () => { }); it("hides the whole section (heading + grid) when no vital has content", () => { - mockBatch(() => insufficient("no_readings_in_window")); - const html = render(); + const batch = mockBatch(() => insufficient("no_readings_in_window")); + const html = render(); // No content under the heading → the heading must not strand over blank // space. The whole section un-mounts. expect(html).not.toContain('data-slot="vitals-dashboard"'); @@ -104,8 +96,8 @@ describe("", () => { }); it("renders a skeleton row (no tiles, no heading strand) while loading", () => { - mockBatch(() => insufficient("no_readings_in_window"), { isLoading: true }); - const html = render(); + const batch = mockBatch(() => insufficient("no_readings_in_window"), { isLoading: true }); + const html = render(); // Heading + grid present so the section reserves its final height, with a // busy/live region and skeleton placeholders — but no real tiles yet. expect(html).toContain('data-slot="vitals-dashboard"'); @@ -116,8 +108,8 @@ describe("", () => { }); it("shows an inline error + retry when the batch fails", () => { - mockBatch(() => insufficient("no_readings_in_window"), { isError: true }); - const html = render(); + const batch = mockBatch(() => insufficient("no_readings_in_window"), { isError: true }); + const html = render(); // The surface must not silently vanish — a compact error + a Retry button. expect(html).toContain('data-slot="vitals-dashboard-error"'); expect(html).toContain('role="alert"'); @@ -127,13 +119,13 @@ describe("", () => { }); it("shows a provisional baseline tile with the coverage meter, not a headline", () => { - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "VITALS_BASELINE" && token.type === "WEIGHT") { return insufficient("insufficient_history_for_band", 4); } return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(html).toContain('data-metric="WEIGHT"'); expect(html).toContain('data-state="provisional"'); expect(html).toContain('data-slot="coverage-meter"'); @@ -141,40 +133,40 @@ describe("", () => { }); it("renders the fitness-age tile with its age framing", () => { - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "FITNESS_AGE") { return ok({ vo2Max: 46, band: "green", fitnessAgeDeltaYears: -6, referenceBand: { low: 35, high: 45 }, trendDelta: 2, readingCount: 4 }); } return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(html).toContain('data-metric="FITNESS_AGE"'); expect(html).toContain("yr younger"); }); it("renders the BMI tile with its WHO category", () => { - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "BMI") { return ok({ bmi: 24.7, category: "normal", band: "green", weightKg: 80, heightCm: 180 }); } return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(html).toContain('data-metric="BMI"'); expect(html).toContain("Normal range (WHO)"); }); it("hides BMI when there is no height/weight", () => { - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "BMI") return insufficient("no_height_on_profile"); return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(html).not.toContain('data-metric="BMI"'); }); it("renders the estimated 6-minute-walk band tile with its percent framing", () => { - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "SIX_MINUTE_WALK_BAND") { return ok({ distanceM: 540, @@ -188,14 +180,14 @@ describe("", () => { } return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(html).toContain('data-slot="vitals-mobility"'); expect(html).toContain('data-metric="SIX_MINUTE_WALK_BAND"'); expect(html).toContain("90% of predicted"); }); it("renders the 6-minute-walk tile distance-only when demographics are absent", () => { - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "SIX_MINUTE_WALK_BAND") { return ok({ distanceM: 540, @@ -209,28 +201,28 @@ describe("", () => { } return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(html).toContain('data-metric="SIX_MINUTE_WALK_BAND"'); // Honest prompt, never a fabricated placement. expect(html).toContain("Add your age, height, weight and sex"); }); it("renders a stair-ascent-speed baseline band tile under Mobility & body", () => { - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "STAIR_ASCENT_SPEED_BASELINE") { return ok({ type: "STAIR_ASCENT_SPEED", center: 0.4, low: 0.3, high: 0.5, spread: 0.05, sampleDays: 21, k: 3, series: [0.38, 0.4, 0.42] }); } return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(html).toContain('data-slot="vitals-mobility"'); expect(html).toContain('data-metric="STAIR_ASCENT_SPEED_BASELINE"'); expect(html).toContain("Mobility & body"); }); it("hides the whole Mobility & body section when no mobility metric has content", () => { - mockBatch(() => insufficient("no_readings_in_window")); - const html = render(); + const batch = mockBatch(() => insufficient("no_readings_in_window")); + const html = render(); expect(html).not.toContain('data-slot="vitals-mobility"'); expect(html).not.toContain("Mobility & body"); expect(html).not.toContain('data-metric="SIX_MINUTE_WALK_BAND"'); @@ -241,11 +233,11 @@ describe("", () => { // The flag moved to the dedicated `CoincidentDeviationCard`; the dashboard // batch no longer requests it and the grid never paints it. let requestedCoincident = false; - mockBatch((token) => { + const batch = mockBatch((token) => { if (token.metric === "COINCIDENT_DEVIATION") requestedCoincident = true; return insufficient("no_readings_in_window"); }); - const html = render(); + const html = render(); expect(requestedCoincident).toBe(false); expect(html).not.toContain('data-metric="COINCIDENT_DEVIATION"'); }); diff --git a/src/components/insights/derived/__tests__/wellness-scores.test.tsx b/src/components/insights/derived/__tests__/wellness-scores.test.tsx new file mode 100644 index 000000000..54e66eb52 --- /dev/null +++ b/src/components/insights/derived/__tests__/wellness-scores.test.tsx @@ -0,0 +1,105 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { + DerivedMetricResponse, + DerivedBatchToken, +} from "../use-derived-metric"; +import { WellnessScores } from "../wellness-scores"; + +function render(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +type Resp = DerivedMetricResponse>; + +function ok(value: Record): Resp { + return { + metric: "X", + status: "ok", + value, + coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] }, + confidence: { score: 90, band: "high" }, + provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "x" }, + reason: null, + }; +} + +function absent(): Resp { + return { + metric: "X", + status: "insufficient", + value: null, + coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: [] }, + confidence: null, + provenance: { inputs: [], source: "none", windowDays: 30, computedAt: "x" }, + reason: "no_readings_in_window", + }; +} + +function readFrom(map: Record): ( + token: DerivedBatchToken, +) => DerivedMetricResponse | null { + return (token: DerivedBatchToken) => + (map[token.metric] ?? absent()) as unknown as DerivedMetricResponse; +} + +describe("", () => { + it("un-mounts the whole strip when no score has data", () => { + const html = render( + , + ); + expect(html).toBe(""); + }); + + it("renders nothing while loading (the page reserves the row)", () => { + const html = render( + , + ); + // While loading no tiles resolve, so the strip stays un-mounted. + expect(html).toBe(""); + }); + + it("renders a ring tile with a canonical TileHeader icon + heading", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="wellness-scores"'); + expect(html).toContain('data-metric="READINESS"'); + // Each tile leads with the shared TileHeader (icon + heading row), with + // the metric name in the foreground-colour CardTitle. + expect(html).toContain('data-slot="tile-header"'); + expect(html).toContain('data-slot="card-title"'); + expect(html).toContain("Readiness"); + // The ring carries its band on the arc + data-attribute (not the number). + expect(html).toContain('data-band="green"'); + // The band word renders under the ring. + expect(html).toContain('data-slot="wellness-score-band-word"'); + }); + + it("renders one tile per available score and hides absent ones", () => { + const html = render( + , + ); + expect(html).toContain('data-metric="READINESS"'); + expect(html).toContain('data-metric="SLEEP_SCORE"'); + expect(html).not.toContain('data-metric="RECOVERY_SCORE"'); + expect(html).not.toContain('data-metric="STRESS_SCORE"'); + expect(html).not.toContain('data-metric="STRAIN_SCORE"'); + }); +}); diff --git a/src/components/insights/derived/score-ring.tsx b/src/components/insights/derived/score-ring.tsx index 0f75c40e7..ccd2e647c 100644 --- a/src/components/insights/derived/score-ring.tsx +++ b/src/components/insights/derived/score-ring.tsx @@ -12,7 +12,6 @@ import { cn } from "@/lib/utils"; import { useCountUp } from "@/hooks/use-count-up"; import { bandForScore, - BAND_NUMBER_CLASS, BAND_VAR, clampScore, type ScoreBand, @@ -156,12 +155,16 @@ export function ScoreRing({ className={cn( "font-semibold tabular-nums", dims.numberClass, - hasScore - ? BAND_NUMBER_CLASS[resolvedBand].replace( - "text-", - "fill-", - ) - : "fill-muted-foreground", + // v1.12.6 — the centred number reads in the + // foreground colour (white on the dark wellness + // card, near-black on the light card), never the + // band tint. The band-coloured number cleared only + // ~1.5:1 on `--card` and read as illegible. The + // band semantic is carried by the ring arc + // (`fill` = BAND_VAR) + the `data-band` attribute + + // the aria-label, so dropping it from the number + // costs no information while clearing AA. + hasScore ? "fill-foreground" : "fill-muted-foreground", )} > {hasScore ? displayedRounded : "—"} diff --git a/src/components/insights/derived/sparkline-delta-tile.tsx b/src/components/insights/derived/sparkline-delta-tile.tsx index 1acb2cb8e..6589120bf 100644 --- a/src/components/insights/derived/sparkline-delta-tile.tsx +++ b/src/components/insights/derived/sparkline-delta-tile.tsx @@ -125,9 +125,17 @@ export function SparklineDeltaTile({ className, )} > -
+ {/* Header row matches the canonical TileHeader language: a leading + foreground icon + a foreground `CardTitle`-weight heading — never + the old muted-uppercase caption. Kept on one line for the dense + grid; provenance trails to the right edge. */} +
+
; + +/** + * Mount the one overview-wide derived batch. `enabled` gates the fetch on the + * page's auth state; the hook itself also gates on the session internally. + */ +export function useDashboardDerived(enabled: boolean): DashboardDerived { + const tokens = useMemo(() => dashboardTokens(), []); + return useDerivedBatch(tokens, { enabled }); +} diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index 0bfb8ccb0..2fca7b851 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -5,23 +5,23 @@ import { Footprints, Gauge, HeartPulse, RefreshCw, Scale } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; -import { useAuth } from "@/hooks/use-auth"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { getUnitForType } from "@/lib/validations/measurement"; import { MEASUREMENT_TYPE_ICONS, MEASUREMENT_TYPE_LABEL_KEYS, } from "@/components/measurements/measurement-list-meta"; +import { TileHeader } from "@/components/insights/tile-header"; import { SparklineDeltaTile } from "./sparkline-delta-tile"; import { CoverageMeter } from "./coverage-meter"; -import { WellnessScores } from "./wellness-scores"; import { ProvenanceExplainer } from "./provenance-explainer"; import { METRIC_PROVENANCE } from "./standards"; +import { type DerivedBatchRead } from "./use-derived-metric"; import { - useDerivedBatch, - type DerivedBatchRead, - type DerivedBatchToken, -} from "./use-derived-metric"; + SECTION_VITALS, + SECTION_MOBILITY, + type DashboardDerived, +} from "./use-dashboard-derived"; import type { TrendDirectionSentiment } from "@/lib/insights/trend-sentiment"; // Type-only — the compute payloads never drag the server graph into the bundle. import type { VitalsBaselineValue } from "@/lib/insights/derived/baseline"; @@ -53,29 +53,9 @@ import type { DerivedProvenance } from "@/lib/insights/derived/types"; * the "app hangs then recovers" symptom (v1.9.1 / v1.4.49.1). */ -const SECTION_VITALS: string[] = [ - "RESTING_HEART_RATE", - "HEART_RATE_VARIABILITY", - "RESPIRATORY_RATE", - "OXYGEN_SATURATION", - "BODY_TEMPERATURE", - "BLOOD_GLUCOSE", - "WEIGHT", -]; - -/** - * v1.10.3 — the any-user HealthKit baseline bands, each pinning one input and - * framing as a personal typical range (median ± k·MAD). The `metric` is the - * batch token + provenance key; `type` drives icon/label/unit. The estimated - * 6-minute-walk re-frame rides the same Mobility section but reads its own - * value shape (`SixMinuteWalkTile`). Most users carry none of these, so the - * tiles simply do not appear (the per-tile absent gate). - */ -const SECTION_MOBILITY: { metric: keyof typeof METRIC_PROVENANCE; type: string }[] = [ - { metric: "STAIR_ASCENT_SPEED_BASELINE", type: "STAIR_ASCENT_SPEED" }, - { metric: "STAIR_DESCENT_SPEED_BASELINE", type: "STAIR_DESCENT_SPEED" }, - { metric: "WRIST_TEMPERATURE_BASELINE", type: "WRIST_TEMPERATURE" }, -]; +// v1.12.6 — `SECTION_VITALS`, `SECTION_MOBILITY` and the batch token list +// moved to `use-dashboard-derived.ts` so the page can own the one shared batch +// and feed it to both the wellness strip and this grid. Imported above. /** Up-is-bad for the vitals where a rise is unfavourable. */ const UP_BAD_VITALS = new Set([ @@ -111,7 +91,13 @@ function vitalSentiment(type: string): TrendDirectionSentiment { } interface DashboardProps { - enabled?: boolean; + /** + * The shared overview derived batch (owned by the page so the wellness + * strip and this vitals grid read the same one request). Lifted out of + * this component in v1.12.6 — the wellness strip now renders as its own + * full-width section above the daily briefing. + */ + batch: DashboardDerived; className?: string; } @@ -197,12 +183,7 @@ function BaselineTile({ data-state="provisional" className="bg-card border-border flex h-full w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6" > -
- - {label} - - -
+

{t("insights.derived.vitals.building", { count: data.coverage.historyDays, @@ -367,12 +348,11 @@ function HrvBalanceTile({ read, isLoading }: TileProps) { data-state="provisional" className="bg-card border-border flex h-full w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6" > -

- - {t("measurements.typeHeartRateVariability")} - - -
+

{t("insights.derived.vitals.building", { count: data.coverage.historyDays, @@ -521,47 +501,10 @@ function hasRenderableMobility(read: DerivedBatchRead): boolean { return false; } -/** - * The full set of tokens the dashboard reads in one batch — the five - * wellness scores + the four derived re-frames + one baseline per vital - * (minus HRV, which has its own balance tile) + the four v1.10.3 mobility/body - * metrics (estimated 6-minute-walk band + the three HealthKit baseline bands). - * The coincident-deviation flag is no longer read here: it is now the - * dedicated "Today's signal" card at the top of the overview - * (`CoincidentDeviationCard`). - */ -function dashboardTokens(): DerivedBatchToken[] { - const tokens: DerivedBatchToken[] = [ - { metric: "READINESS" }, - { metric: "SLEEP_SCORE" }, - { metric: "RECOVERY_SCORE" }, - { metric: "STRESS_SCORE" }, - { metric: "STRAIN_SCORE" }, - { metric: "FITNESS_AGE" }, - { metric: "VASCULAR_AGE_DELTA" }, - { metric: "HRV_BALANCE" }, - { metric: "BMI" }, - { metric: "SIX_MINUTE_WALK_BAND" }, - ]; - for (const type of SECTION_VITALS) { - if (type === "HEART_RATE_VARIABILITY") continue; - tokens.push({ metric: "VITALS_BASELINE", type }); - } - for (const { metric } of SECTION_MOBILITY) { - tokens.push({ metric }); - } - return tokens; -} - -export function VitalsDashboard({ enabled = true, className }: DashboardProps) { +export function VitalsDashboard({ batch, className }: DashboardProps) { const { t } = useTranslations(); - const { isAuthenticated } = useAuth(); const vitals = useMemo(() => SECTION_VITALS, []); - const tokens = useMemo(() => dashboardTokens(), []); - const batch = useDerivedBatch(tokens, { - enabled: enabled && isAuthenticated, - }); const read = batch.read; const isLoading = batch.isLoading; const isError = batch.isError; @@ -583,9 +526,10 @@ export function VitalsDashboard({ enabled = true, className }: DashboardProps) { aria-label={t("insights.derived.vitals.sectionTitle")} className="space-y-3" > -

- {t("insights.derived.vitals.sectionTitle")} -

+
- {showSection && (
-

- {t("insights.derived.vitals.sectionTitle")} -

+
-

- {t("insights.derived.vitals.mobilitySectionTitle")} -

+
void; className?: string; } @@ -44,6 +57,7 @@ function RingTile({ bandWord, href, metricSlot, + icon, }: { score: number; band: ScoreBand; @@ -51,28 +65,26 @@ function RingTile({ bandWord: string; href: string; metricSlot: string; + icon: ComponentType<{ className?: string }>; }) { return ( -
- {/* The metric title rides an HTML caption below the ring, not the - in-SVG `label` slot: a long localised title ("Schlaf-Score", - "Bereitschaft") overflowed the small ring's centred SVG text and - the recharts `overflow:hidden` clipped its leading glyph. As HTML - it wraps within the tile and never truncates. The ring keeps just - the number centred. */} + {/* v1.12.6 — every tile leads with the canonical icon + heading + (foreground colour, matching the Einschätzung reference) so the + wellness strip reads as the same card language as the rest of + Insights. The metric name no longer rides an under-ring caption. */} + +
+ {/* The ring keeps just the number centred; the metric name lives in + the TileHeader above (a long localised title would overflow the + small ring's centred SVG text and clip under recharts' + `overflow:hidden`). */} - - {label} - + +
+ {t("insights.derived.scores.loadError")} + +
+
+ ); + } + const readiness = read({ metric: "READINESS" }); const sleep = read({ metric: "SLEEP_SCORE" }); const recovery = read({ metric: "RECOVERY_SCORE" }); @@ -108,6 +162,7 @@ export function WellnessScores({ read, isLoading, className }: ScoreStripProps) bandWord={bandWord(readiness.value.band)} href="/insights/scores/readiness" metricSlot="READINESS" + icon={Gauge} />, ); } @@ -121,6 +176,7 @@ export function WellnessScores({ read, isLoading, className }: ScoreStripProps) bandWord={bandWord(recovery.value.band)} href="/insights/scores/recovery" metricSlot="RECOVERY_SCORE" + icon={HeartPulse} />, ); } @@ -134,6 +190,7 @@ export function WellnessScores({ read, isLoading, className }: ScoreStripProps) bandWord={bandWord(sleep.value.band)} href="/insights/scores/sleep" metricSlot="SLEEP_SCORE" + icon={Moon} />, ); } @@ -147,6 +204,7 @@ export function WellnessScores({ read, isLoading, className }: ScoreStripProps) bandWord={bandWord(stress.value.band)} href="/insights/scores/stress" metricSlot="STRESS_SCORE" + icon={Activity} />, ); } @@ -160,6 +218,7 @@ export function WellnessScores({ read, isLoading, className }: ScoreStripProps) bandWord={bandWord(strain.value.band)} href="/insights/scores/strain" metricSlot="STRAIN_SCORE" + icon={Flame} />, ); } @@ -187,9 +246,10 @@ export function WellnessScores({ read, isLoading, className }: ScoreStripProps) aria-label={t("insights.derived.scores.sectionTitle")} className={cn("space-y-3", className)} > -

- {t("insights.derived.scores.sectionTitle")} -

+
` (the + * numbers-first block above the chart). Pass the metric's icon component + * (e.g. `Activity`, `Droplet`); when omitted the strip falls back to a + * generic stats glyph so every HealthKit page still leads with a complete + * header carrying the metric name. + */ + statIcon?: ComponentType<{ className?: string }>; /** * Optional Coach prefill for the empty-state launch. Falls back to a * generic onboarding prompt threaded through ``'s @@ -148,6 +156,7 @@ export function HealthKitMetricPage({ explainerMetric, targetSummarySlug, statusMetric, + statIcon, forecast = false, }: HealthKitMetricPageProps) { const { user, isAuthenticated } = useAuth(); @@ -210,7 +219,14 @@ export function HealthKitMetricPage({ title={title} description={description} explainerMetric={explainerMetric} - statStrip={} + statStrip={ + + } diversityNudge={ `. Callers still pass a pre-sized icon NODE (e.g. + * ``) because the bespoke metric pages + * own that prop; `` drives its icon off a component, so wrap + * the node in a thin component that renders it verbatim. The node already + * carries the `h-5 w-5` foreground sizing `` would apply, so + * the rendered row is byte-identical to the previous hand-rolled `flex + * items-center gap-2` header. + */ +function nodeIcon(icon: React.ReactNode): React.ComponentType { + return function StatusIcon() { + return <>{icon}; + }; +} + interface InsightStatusCardProps { title: string; icon: React.ReactNode; @@ -59,10 +76,7 @@ export function InsightStatusCard({ className="gap-2 py-4 md:gap-3 md:py-5" > -
- {icon} - {title} -
+
@@ -115,10 +129,7 @@ export function InsightStatusCard({ return ( -
- {icon} - {title} -
+

@@ -133,10 +144,7 @@ export function InsightStatusCard({ return ( -

- {icon} - {title} -
+

@@ -175,11 +183,9 @@ export function InsightStatusCard({ {/* v1.11.5 — the top-right "cached" label was removed: it surfaced an implementation detail and devalued the assessment. The card - still consumes the warm cache; it just no longer announces it. */} -

- {icon} - {title} -
+ still consumes the warm cache; it just no longer announces it. + v1.12.6 — the icon + title row is the canonical ``. */} + {/* v1.4.27 — defence-in-depth strip. Cached status text from diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index cf7f2e4e9..2f6d31349 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -1,8 +1,10 @@ "use client"; import { useMemo, type ReactNode } from "react"; +import { useSelectedLayoutSegment } from "next/navigation"; import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "@/lib/i18n/context"; import { useAuth } from "@/hooks/use-auth"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; import { useWorkouts } from "@/hooks/use-workouts"; @@ -39,6 +41,7 @@ interface ComprehensivePayload { } export function InsightsLayoutShell({ children }: { children: ReactNode }) { + const { t } = useTranslations(); const { isAuthenticated } = useAuth(); // v1.4.33 F18 — gate the advisor POST on the operator's assistant // feature flag. Pre-fix, every /insights mount fired POST @@ -55,6 +58,15 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { const flags = useFeatureFlags(); const advisorEnabled = isAuthenticated && flags.enabled && flags.briefing; + + // v1.12.6 — routes that carry their own purpose-built disclaimer must NOT + // also show the generic footer below (otherwise two disclaimers stack). + // The Coach full page renders its own `composerDisclaimer` under the + // composer, so its segment is excluded here. Every other segment — the + // overview (null segment), the metric sub-pages, mood, medications, the + // raw values table — keeps the single generic footer. + const segment = useSelectedLayoutSegment(); + const showDisclaimerFooter = segment !== "coach"; const advisor = useInsightsAdvisorQuery(advisorEnabled); // Shared analytics fetch — the layout shell only reads @@ -108,6 +120,24 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { availability={availability} /> {children} + {/* v1.12.6 — SINGLE SOURCE of the generic Insights disclaimer. + The "describes your own data, not a clinical assessment / diagnosis" + sentence used to repeat across the overview, the metric tiles, the + mood page, and the medications page. It now lives here once, as a + page-level footer under every `/insights/*` route. Metric-SPECIFIC + caveats (health score, correlations, mood relations, derived cards, + rhythm events) stay on their own cards — those convey real, + non-generic information. Other waves remove the generic line from the + overview / mood / medications pages; this footer is where it lives. + Suppressed on the Coach route, which carries its own disclaimer. */} + {showDisclaimerFooter ? ( +

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

+ ) : null}
); } diff --git a/src/components/insights/metric-stat-strip.tsx b/src/components/insights/metric-stat-strip.tsx index 75af6b489..b89aa63a5 100644 --- a/src/components/insights/metric-stat-strip.tsx +++ b/src/components/insights/metric-stat-strip.tsx @@ -1,6 +1,11 @@ "use client"; +import type { ComponentType } from "react"; +import { Sigma } from "lucide-react"; + import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; import type { DataSummary } from "@/lib/analytics/trends"; /** @@ -41,12 +46,26 @@ interface MetricStatStripProps { */ fractionDigits?: number; /** - * v1.12.4 — optional series caption rendered above the grid. Single-series - * metrics omit it; blood pressure stacks two strips (systolic / diastolic) - * and labels each so the unified strip covers both halves rather than - * silently dropping one. Also feeds the `aria-label` for the section. + * v1.12.4 — series caption rendered above the grid. Single-series metrics + * pass the metric name (e.g. "Gewicht"); blood pressure stacks two strips + * (systolic / diastolic) and labels each so the unified strip covers both + * halves rather than silently dropping one. Also feeds the `aria-label` + * for the section. + * + * v1.12.6 — the caption is now rendered through the canonical + * `` (white heading + white icon, matching the Einschätzung + * card) rather than a small muted uppercase line, so every series block + * leads with the same header language as the rest of the surface. */ seriesLabel?: string; + /** + * v1.12.6 — leading glyph for the series ``. Pass the metric's + * icon component (e.g. `Scale`, `Heart`); blood pressure passes a + * directional glyph per series. Defaults to a generic stats glyph so a + * caller that supplies a `seriesLabel` without an icon still renders a + * complete header. + */ + icon?: ComponentType<{ className?: string }>; } export function MetricStatStrip({ @@ -54,6 +73,7 @@ export function MetricStatStrip({ unit, fractionDigits = 1, seriesLabel, + icon, }: MetricStatStripProps) { const { t } = useTranslations(); const fmt = useFormatters(); @@ -81,40 +101,44 @@ export function MetricStatStrip({ ]; return ( -
` tiles on the same subpage spine (assessment, mood, medication) + // rather than the old bare-div `space-y-3`. The grid keeps its own + // data-slots; the section semantics ride on `role` + `aria-label`. + {seriesLabel ? ( -

- {seriesLabel} -

+ + + ) : null} -
- {cells.map((cell) => ( -
-

- {cell.label} -

-

- {format(cell.value)} -

+ +
+ {cells.map((cell) => ( +
+

+ {cell.label} +

+

+ {format(cell.value)} +

+
+ ))}
- ))} -
-
+
+ ); } diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx index bda5faf12..a05416cd8 100644 --- a/src/components/insights/metric-target-summary.tsx +++ b/src/components/insights/metric-target-summary.tsx @@ -8,9 +8,11 @@ import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; import { useTranslations } from "@/lib/i18n/context"; import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { RangeBar } from "@/components/targets/range-bar"; import { ConsistencyStrip } from "@/components/targets/consistency-strip"; import { TargetStatusPill } from "@/components/targets/target-status-pill"; +import { TileHeader } from "@/components/insights/tile-header"; import { useTargetAdjust } from "@/lib/insights/target-adjust-context"; import { getTargetSourceLink } from "@/lib/targets/source-link"; @@ -121,11 +123,6 @@ interface MetricTargetSummaryProps { slug: string; } -/** Trim a trailing `.0` so whole-number bands read `120` not `120.0`. */ -function fmt(value: number): string { - return Number.isInteger(value) ? String(value) : value.toFixed(1); -} - export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); @@ -275,27 +272,6 @@ function TargetReferencePanel({ const isBp = target.type === "BLOOD_PRESSURE"; const isMedicationCompliance = target.type === "MEDICATION_COMPLIANCE"; - // Compose the range string. Blood pressure stitches the systolic band - // (on this target) with the diastolic band (on `bpDiastolic`) so the - // user reads the familiar `S/D` pair. - let rangeLabel: string; - if (isBp && bpDiastolic?.range) { - const dia = bpDiastolic.range; - rangeLabel = t("insights.subPage.target.bpRange", { - sysMin: fmt(range.min), - sysMax: fmt(range.max), - diaMin: fmt(dia.min), - diaMax: fmt(dia.max), - unit, - }); - } else { - rangeLabel = t("insights.subPage.target.range", { - min: fmt(range.min), - max: fmt(range.max), - unit, - }); - } - const sourceLink = getTargetSourceLink(target); // v1.12.0 — the in-target share (% of logged days in band) and the @@ -322,40 +298,45 @@ function TargetReferencePanel({ target.consistency7d.length > 0; return ( -
` tiles on the same subpage spine rather than the old bare-div + // `space-y-3`. The body keeps its inter-row `space-y-3` inside + // `CardContent`; every data-slot is preserved. + - {/* Row 1: target range string + status pill + source link. */} -
-
-
-
- {target.classification ? ( - - ) : null} -
-
- - {/* Row 2: range bar — where today's value sits inside the band. */} + {/* Row 1: the canonical tile header — a single-word "Ziel" / "Target" + heading (the range numbers live on the range bar below, so the + old "Target: 120–129 mmHg" string would just duplicate them) with + the status pill pinned to the trailing edge. For glucose the + per-context label (Fasting / Postprandial / …) carries real, + non-duplicative info, so it stays as a sub-caption under the + header. */} + + + ) : null + } + /> + {heading ? ( +

+ {heading} +

+ ) : null} +
+ + {/* Row 2: range bar — where today's value sits inside the band. */} {target.current != null ? ( )} -
-
+
+ + ); } diff --git a/src/components/insights/mood/__tests__/mood-better-days.test.tsx b/src/components/insights/mood/__tests__/mood-better-days.test.tsx index c2d483750..5f7c048b1 100644 --- a/src/components/insights/mood/__tests__/mood-better-days.test.tsx +++ b/src/components/insights/mood/__tests__/mood-better-days.test.tsx @@ -80,12 +80,14 @@ describe("", () => { expect(html).toContain("r -0.45"); }); - it("renders the description and disclaimer once each", () => { + it("renders the description once and no local generic disclaimer", () => { const html = render( , ); - expect((html.match(/Associations only/g) ?? []).length).toBe(1); expect((html.match(/ranked by how strongly/g) ?? []).length).toBe(1); + // The generic "associations only, not causes" disclaimer now lives once + // in the page-level Insights footer, not on this board. + expect(html).not.toContain("Associations only"); // one row per factor expect((html.match(/data-slot="mood-better-day-factor"/g) ?? []).length).toBe( 2, diff --git a/src/components/insights/mood/__tests__/mood-tag-influence.test.tsx b/src/components/insights/mood/__tests__/mood-tag-influence.test.tsx index 317409bc5..e08d4f1b9 100644 --- a/src/components/insights/mood/__tests__/mood-tag-influence.test.tsx +++ b/src/components/insights/mood/__tests__/mood-tag-influence.test.tsx @@ -79,10 +79,12 @@ describe("", () => { expect(html).not.toContain("charts.weekdaysFull.mon"); }); - it("renders the observational disclaimer once", () => { + it("does not render a local generic disclaimer", () => { const html = render( , ); - expect((html.match(/not proof of cause/g) ?? []).length).toBe(1); + // The generic "associations, not causes" caveat now lives once in the + // page-level Insights footer, not on this list. + expect(html).not.toContain("not proof of cause"); }); }); diff --git a/src/components/insights/mood/mood-better-days.tsx b/src/components/insights/mood/mood-better-days.tsx index 4a489a4e7..19a7b9873 100644 --- a/src/components/insights/mood/mood-better-days.tsx +++ b/src/components/insights/mood/mood-better-days.tsx @@ -15,7 +15,8 @@ import type { MoodInfluenceConfidence } from "./mood-tag-influence"; * surface — the headline "relations" answer to "what goes with my good * days?". The merge + ranking is pre-computed in `mood-aggregates.ts` * (`computeBetterDays`); this component only paints. Observational only; - * the standing disclaimer is rendered once below the board. + * the standing "describes your own data, not a diagnosis" disclaimer lives + * once in the page-level Insights footer, not on this board. */ export interface MoodBetterDayFactor { @@ -138,9 +139,6 @@ export function MoodBetterDays({ ); })} -

- {t("insights.mood.betterDays.disclaimer")} -

); } diff --git a/src/components/insights/mood/mood-discovered-relations.tsx b/src/components/insights/mood/mood-discovered-relations.tsx index 4acf20058..6e087b84c 100644 --- a/src/components/insights/mood/mood-discovered-relations.tsx +++ b/src/components/insights/mood/mood-discovered-relations.tsx @@ -1,7 +1,10 @@ "use client"; import { useQuery } from "@tanstack/react-query"; +import { Compass } from "lucide-react"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; import { useTranslations } from "@/lib/i18n/context"; @@ -81,26 +84,32 @@ export function MoodDiscoveredRelations() { if (moodPairs.length === 0) return null; return ( -
-
-

- {t("insights.mood.discovery.title")} -

+ + {/* v1.12.4 (C3) — the false-discovery footer + observational disclaimer were two full-width footnote rows. Fold both into a single explainer icon so the explanation is one hover/focus away - instead of a low-density block under the list. */} - + } /> -
-

- {t("insights.mood.discovery.description")} -

-
    + + +

    + {t("insights.mood.discovery.description")} +

    +
      {moodPairs.map((pair) => { const moodIsOutcome = pair.outcome === "MOOD"; const channelKey = moodIsOutcome ? pair.behaviour : pair.outcome; @@ -139,7 +148,8 @@ export function MoodDiscoveredRelations() { ); })} -
    -
+ + + ); } diff --git a/src/components/insights/mood/mood-in-target-tile.tsx b/src/components/insights/mood/mood-in-target-tile.tsx index 6e3241e90..9f19c6bbc 100644 --- a/src/components/insights/mood/mood-in-target-tile.tsx +++ b/src/components/insights/mood/mood-in-target-tile.tsx @@ -2,7 +2,8 @@ import { Target } from "lucide-react"; -import { Card, CardContent } from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; import { useTranslations } from "@/lib/i18n/context"; /** @@ -12,6 +13,10 @@ import { useTranslations } from "@/lib/i18n/context"; * the LLM snapshot) to a visible stat: the share of recent days that * landed in the good-mood band. Renders nothing when the number is * unavailable (no recent data) so the page degrades gracefully. + * + * v1.12.6 — the tile now leads with the canonical `TileHeader` + * (icon + white heading) so it speaks the same card language as every + * other Insights tile; the icon-in-a-circle headline glyph is dropped. */ export function MoodInTargetTile({ pct }: { pct: number | null }) { const { t } = useTranslations(); @@ -22,18 +27,16 @@ export function MoodInTargetTile({ pct }: { pct: number | null }) { return ( - -
- -
-
-

- {t("insights.mood.inTargetValue", { pct: String(rounded) })} -

-

- {t("insights.mood.inTargetCaption")} -

-
+ + + + +

+ {t("insights.mood.inTargetValue", { pct: String(rounded) })} +

+

+ {t("insights.mood.inTargetCaption")} +

); diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx index 7271c786e..061871ca1 100644 --- a/src/components/insights/mood/mood-insights-sections.tsx +++ b/src/components/insights/mood/mood-insights-sections.tsx @@ -3,7 +3,22 @@ import dynamic from "next/dynamic"; import { useQuery } from "@tanstack/react-query"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import type { ComponentType } from "react"; +import { + BarChart3, + CalendarDays, + CalendarRange, + Clock, + Grid3x3, + Link2, + Sparkles, + Tag, + Tags, + TrendingUp, +} from "lucide-react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; @@ -136,15 +151,19 @@ interface MoodInsightsResponse { function SectionCard({ title, + icon, children, }: { title: string; + // v1.12.6 — every mood section header now routes through `TileHeader` + // (icon + white heading) so the subpage reads as one card language. + icon: ComponentType<{ className?: string }>; children: React.ReactNode; }) { return ( - {title} + {children} @@ -210,7 +229,14 @@ export function MoodInsightsSections() { const hasCrosstab = crosstabRows.length > 0; return ( + // v1.12.6 — the mood spine is ordered as: Einordnung (the classification + // tiles: in-target share + stability band) FIRST, then the "what goes with + // your better days" assessment read, then the descriptive breakdown + // sections. The classification answers "where do I stand", the better-days + // board answers "what associates with my good days", and the rest is the + // supporting detail.
+ {/* Einordnung — the classification tiles. */} {(hasInTargetTile || hasStabilityTile) && ( // Two-up only when BOTH tiles render; a lone tile spans full width so // it never leaves a half-width orphan with dead space beside it. @@ -229,15 +255,25 @@ export function MoodInsightsSections() {
)} - - + {/* "Was zu deinen besseren Tagen passt" — the better-days assessment, + ranked by effect size. The substance of the Einschätzung sits here, + directly under the classification. */} {hasBetterDays && ( - + )} - + {narratives.length > 0 && ( + + + + )} + +
- + - +
{data.timeOfDay.reliable && ( - + )} {hasStructuredTags && ( - + )} {hasTags && ( - + )} {hasInfluence && ( - + )} {hasCrosstab && ( - + )} -
-

- {t("insights.mood.correlationsTitle")} -

-

+ +

{t("insights.mood.correlationsDescription")}

-
+
{/* F3 — the FDR-controlled discovered mood relations. Self-fetches the correlation-discovery surface and renders nothing when the operator diff --git a/src/components/insights/mood/mood-stability-tile.tsx b/src/components/insights/mood/mood-stability-tile.tsx index cc53e9ba5..32da9e524 100644 --- a/src/components/insights/mood/mood-stability-tile.tsx +++ b/src/components/insights/mood/mood-stability-tile.tsx @@ -2,7 +2,8 @@ import { Activity } from "lucide-react"; -import { Card, CardContent } from "@/components/ui/card"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; import { useTranslations } from "@/lib/i18n/context"; /** @@ -46,20 +47,21 @@ export function MoodStabilityTile({ return ( - -
- -
-
-

- {t("insights.mood.stability.value", { - score: String(stability.score), - })} -

-

- {t(BAND_KEY[stability.band])} -

-
+ + + + +

+ {t("insights.mood.stability.value", { + score: String(stability.score), + })} +

+

+ {t(BAND_KEY[stability.band])} +

); diff --git a/src/components/insights/mood/mood-tag-influence.tsx b/src/components/insights/mood/mood-tag-influence.tsx index e2ca76acd..5ebef5105 100644 --- a/src/components/insights/mood/mood-tag-influence.tsx +++ b/src/components/insights/mood/mood-tag-influence.tsx @@ -12,7 +12,8 @@ import { cn } from "@/lib/utils"; * the delta, and a confidence chip. The math is pre-computed in * `mood-aggregates.ts` (shared with the LLM snapshot), gated on per-group * sample floors — sparse tags never reach here. Observational only: the - * "association, not cause" caption is rendered once below the list. + * generic "associations, not causes" caveat lives once in the page-level + * Insights footer, so this list no longer repeats it. */ export type MoodInfluenceConfidence = "low" | "medium" | "high"; @@ -110,9 +111,6 @@ export function MoodTagInfluence({ ); })} -

- {t("insights.mood.influence.disclaimer")} -

); } diff --git a/src/components/insights/mood/mood-tag-metric-crosstab.tsx b/src/components/insights/mood/mood-tag-metric-crosstab.tsx index 907561769..3c0062672 100644 --- a/src/components/insights/mood/mood-tag-metric-crosstab.tsx +++ b/src/components/insights/mood/mood-tag-metric-crosstab.tsx @@ -14,7 +14,8 @@ import type { MoodInfluenceConfidence } from "./mood-tag-influence"; * pre-computed in `mood-aggregates.ts` (`computeTagMetricCrosstab`) — the * same Welch t-test + day floors + Benjamini-Hochberg FDR the rest of the * relations surface uses; only FDR-surviving rows reach here. Observational - * only: the "association, not cause" caption renders once below the list. + * only: the generic "associations, not causes" caveat lives once in the + * page-level Insights footer, so this card no longer repeats it. */ export type MoodCrosstabDisplay = "hours" | "kcal" | "score"; @@ -150,9 +151,6 @@ export function MoodTagMetricCrosstab({ ); })} -

- {t("insights.mood.crosstab.disclaimer")} -

); } diff --git a/src/components/insights/sub-page-shell.tsx b/src/components/insights/sub-page-shell.tsx index 872d72d61..1b1d42925 100644 --- a/src/components/insights/sub-page-shell.tsx +++ b/src/components/insights/sub-page-shell.tsx @@ -80,10 +80,11 @@ export interface SubPageShellProps { */ focusOnMount?: boolean; /** - * v1.12.4 — the Min / Max / Median / Mittelwert stat strip. It now sits - * at the FOOT of the canonical spine, below the assessment, so every - * metric subpage reads the same: intro → chart → target → assessment → - * stat strip. Sub-pages pass ``; the strip + * v1.12.6 — the Min / Max / Median / Mittelwert stat strip. It now leads + * the canonical spine, directly ABOVE the chart, so every metric subpage + * reads the same: intro → stat strip → chart → target → assessment. + * Numbers-first matches how Apple Health / Withings / Oura lead a detail + * screen. Sub-pages pass ``; the strip * self-gates (renders nothing without data), so the empty-state and * brand-new-metric paths stay clean. Omitted on the empty-state branch. */ @@ -255,17 +256,24 @@ export function SubPageShell({

{description}

) : null} - {/* v1.12.4 — the canonical spine body: intro (header, above) → - chart → target card → assessment, all rendered by the page as - `children`. */} - {children} - {/* v1.12.4 — the Min / Max / Median / Mittelwert stat strip closes - the spine, below the assessment. Self-gating (renders nothing + {/* v1.12.6 — the Min / Max / Median / Mittelwert stat strip LEADS + the spine, directly above the chart. Self-gating (renders nothing without data), so the spacing rhythm holds on the empty-state and - brand-new-metric paths. The diversity nudge rides the heading row; - the headline tile and the "Letzte Messung" card are retired so the - subpage is identical by construction across every metric. */} + brand-new-metric paths. Numbers-first is the Apple-Health / + Withings / Oura detail-screen convention. + + The stats describe the full range. Recomputing them for a window + the user brushes / selects in the chart below would mean lifting the + chart's domain into this shell so both the chart and the strip read + it — but the shared `` (also driving the dashboard) has + no Recharts `` or domain-change callback and keeps its range + state internal, so that wiring is left for a later release to avoid + destabilising the chart. */} {statStrip} + {/* v1.12.6 — the canonical spine body: intro (header) → stat strip + (above) → chart → target card → assessment. The page renders + chart → target → assessment as `children`. */} + {children} {/* v1.8.5 W4b — "show all readings" entry at the foot, linking to the dedicated per-metric values subpage. v1.8.6 — normalised to the `h-10` secondary-button height so it diff --git a/src/components/insights/therapy-timeline.tsx b/src/components/insights/therapy-timeline.tsx deleted file mode 100644 index 4e4a765ab..000000000 --- a/src/components/insights/therapy-timeline.tsx +++ /dev/null @@ -1,213 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import { ArrowDown, ArrowUp, Pill, Syringe } from "lucide-react"; - -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { useTranslations, useFormatters } from "@/lib/i18n/context"; -import { - describeInjectionSite, - type InjectionSiteKey, -} from "@/lib/medications/injection-sites"; - -/** - * v1.4.25 W4d — therapy-timeline view for /insights/medications. - * - * Renders a chronological list of GLP-1 therapy events: dose changes, - * injections (with site if recorded), inventory events, side-effect tag - * occurrences. Pure list view — no chart — so the medications-page - * "no chart on the medication card" rule stays consistent across all - * GLP-1 surfaces; the dose curve lives on the Dashboard tile only. - * - * Self-hides for users with no GLP-1 medications (the parent route does - * the gating via a non-200 from the API endpoint). - */ - -interface TimelineEntry { - date: string; - kind: "dose-change" | "injection" | "inventory" | "side-effect"; - medicationName?: string; - doseValue?: number; - doseUnit?: string; - doseDelta?: "up" | "down" | null; - note?: string | null; - injectionSite?: InjectionSiteKey | null; - inventoryDelta?: number; - reason?: string; - tags?: string[]; -} - -interface TherapyTimelineResponse { - hasGlp1: boolean; - entries: TimelineEntry[]; -} - -interface TherapyTimelineProps { - /** Optional cap — defaults to the API's default (60 entries). */ - limit?: number; -} - -export function TherapyTimeline({ limit }: TherapyTimelineProps) { - const { t } = useTranslations(); - const fmt = useFormatters(); - - const { data, isLoading } = useQuery({ - queryKey: ["insights", "glp1-timeline", limit ?? "default"], - queryFn: async () => { - const url = limit - ? `/api/insights/glp1-timeline?limit=${limit}` - : "/api/insights/glp1-timeline"; - const res = await fetch(url); - if (!res.ok) return null; - const json = await res.json(); - return json.data as TherapyTimelineResponse; - }, - staleTime: 60 * 1000, - }); - - if (isLoading || !data || !data.hasGlp1) return null; - if (data.entries.length === 0) { - return ( - - - - - {t("insights.therapyTimeline.title")} - - - -

- {t("insights.therapyTimeline.empty")} -

-
-
- ); - } - - return ( - - - - - {t("insights.therapyTimeline.title")} - -

- {t("insights.therapyTimeline.description")} -

-
- -
    - {data.entries.map((entry) => ( -
  1. -
  2. - ))} -
-
-
- ); -} - -function colourForKind(kind: TimelineEntry["kind"]): string { - switch (kind) { - case "dose-change": - return "var(--dracula-purple)"; - case "injection": - return "var(--dracula-cyan)"; - case "inventory": - return "var(--dracula-yellow)"; - case "side-effect": - return "var(--dracula-orange)"; - } -} - -function renderEntryLine( - entry: TimelineEntry, - t: (key: string, params?: Record) => string, -): React.ReactNode { - const drug = entry.medicationName ?? ""; - switch (entry.kind) { - case "dose-change": { - const arrow = - entry.doseDelta === "up" ? ( -