Merge pull request #263 from MBombeck/release/v1.12.6

release/v1.12.6 — one consistent Insights surface
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-05 13:14:53 +02:00
commit d4e3533b64
44 files changed
+1127 -793

No files matched your search

+19
View File
@@ -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
+1 -1
View File
@@ -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.
+79
View File
@@ -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 <https://console.cloud.google.com> 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.
+26 -6
View File
@@ -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_WEBHOOK_SECRET>
```
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
+1
View File
@@ -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` |
+14 -20
View File
@@ -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": {
+14 -20
View File
@@ -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": {
+14 -20
View File
@@ -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": {
+14 -20
View File
@@ -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": {
+14 -20
View File
@@ -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": {
+14 -20
View File
@@ -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": {
+1 -1
View File
@@ -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",
@@ -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
* `<SubPageShell>` 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 `<SubPageShell>` 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).
+3 -1
View File
@@ -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}
/>
<MetricStatStrip
summary={diaSummary}
unit="mmHg"
fractionDigits={0}
seriesLabel={t("charts.diastolic")}
icon={ArrowDownRight}
/>
</div>
}
+32 -36
View File
@@ -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 `<MedicationCardHeader>`
// 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 `<TileHeader>` (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 ? (
<Badge variant="outline" className="shrink-0 text-xs">
{t("insights.dayStreak", { count: med.streak })}
</Badge>
) : null;
return (
<Card key={med.id}>
<MedicationCardHeader
name={med.name}
dose={med.dose}
categoryLabel={categoryLabel}
stateBadges={streakBadge}
/>
<CardContent className="space-y-3">
<div className="space-y-1.5">
<Card key={med.id} className="gap-2 py-4 md:gap-3 md:py-5">
<CardHeader className="pb-0">
<TileHeader icon={Pill} title={med.name} />
<div className="text-muted-foreground mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
<span className="font-medium tabular-nums">{med.dose}</span>
<span aria-hidden="true">·</span>
<span>{categoryLabel}</span>
{med.streak > 0 ? (
<Badge variant="outline" className="ml-auto shrink-0 text-xs">
{t("insights.dayStreak", { count: med.streak })}
</Badge>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-2.5">
<div className="space-y-1">
<div className="flex justify-between text-xs">
<span>{t("insights.compliance7d")}</span>
<span className="font-medium">{med.compliance7}%</span>
@@ -238,34 +241,32 @@ export default function InsightsMedikamentePage() {
accessible name (Lighthouse a11y 91/94). */}
<Progress
value={med.compliance7}
className="h-2"
className="h-1.5"
aria-label={t("insights.compliance7d")}
/>
</div>
<div className="space-y-1.5">
<div className="space-y-1">
<div className="flex justify-between text-xs">
<span>{t("insights.compliance30d")}</span>
<span className="font-medium">{med.compliance30}%</span>
</div>
<Progress
value={med.compliance30}
className="h-2"
className="h-1.5"
aria-label={t("insights.compliance30d")}
/>
</div>
<div className="text-muted-foreground flex justify-between text-xs">
<span>
<span className="text-dracula-green">{med.taken7}</span>{" "}
<span className="text-success">{med.taken7}</span>{" "}
{t("insights.taken")}
</span>
<span>
<span className="text-dracula-orange">
{med.skipped7}
</span>{" "}
<span className="text-warning">{med.skipped7}</span>{" "}
{t("insights.skipped")}
</span>
<span>
<span className="text-dracula-red">{med.missed7}</span>{" "}
<span className="text-destructive">{med.missed7}</span>{" "}
{t("insights.missed")}
</span>
</div>
@@ -283,11 +284,6 @@ export default function InsightsMedikamentePage() {
<MetricTargetSummary slug="medications" />
{/* 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. */}
<TherapyTimeline />
{/* 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
+45 -15
View File
@@ -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: () => <BlockSkeleton minHeight="min-h-80" />,
},
);
// 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: () => <BlockSkeleton minHeight="min-h-40" decorative />,
},
);
// 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 <VitalsDashboard>, 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
// <VitalsDashboard> 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 <VitalsDashboard>,
// 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 (
<div className="space-y-8">
<HeroStrip
@@ -300,6 +323,13 @@ export default function InsightsPage() {
healthScore={analytics?.healthScore ?? undefined}
/>
<WellnessScores
read={dashboardDerived.read}
isLoading={dashboardDerived.isLoading}
isError={dashboardDerived.isError}
refetch={dashboardDerived.refetch}
/>
{flags.briefing && (
<DailyBriefing
briefing={briefingPayload}
@@ -310,7 +340,7 @@ export default function InsightsPage() {
/>
)}
<VitalsDashboard enabled={isAuthenticated} />
<VitalsDashboard batch={dashboardDerived} />
<TrendsRow
briefing={briefingPayload}
+8 -1
View File
@@ -113,7 +113,14 @@ export default function InsightsPulsPage() {
title={t("insights.pulseSectionTitle")}
description={t("insights.subPage.pulsDescription")}
explainerMetric="pulse"
statStrip={<MetricStatStrip summary={pulseSummary} unit="bpm" />}
statStrip={
<MetricStatStrip
summary={pulseSummary}
unit="bpm"
seriesLabel={t("insights.pulseSectionTitle")}
icon={Heart}
/>
}
diversityNudge={
<MeasurementDiversityNudge
measurementType="PULSE"
+8 -1
View File
@@ -75,7 +75,14 @@ export default function InsightsGewichtPage() {
title={t("insights.weightSectionTitle")}
description={t("insights.subPage.gewichtDescription")}
explainerMetric="weight"
statStrip={<MetricStatStrip summary={weightSummary} unit="kg" />}
statStrip={
<MetricStatStrip
summary={weightSummary}
unit="kg"
seriesLabel={t("insights.weightSectionTitle")}
icon={Scale}
/>
}
diversityNudge={
<MeasurementDiversityNudge
measurementType="WEIGHT"
+15 -6
View File
@@ -122,15 +122,24 @@ const TIME_RANGES_KEYS = [
// MOOD_LABELS built dynamically via t() in the component
// v1.12.6 — mood-level bands ride the same Dracula good→bad ramp the mood
// heatmap and distribution chart already use (red → orange → yellow →
// green), so the three mood surfaces read as one palette instead of the
// chart's former three-band red/orange/green that skipped the yellow rung.
// Tokens, not raw hex, so a theme change flows through one source.
const VALUE_BANDS = [
{ min: 1, max: 2, color: "#ff5555", opacity: 0.16 },
{ min: 2, max: 3, color: "#ffb86c", opacity: 0.18 },
{ min: 3, max: 5, color: "#50fa7b", opacity: 0.2 },
{ min: 1, max: 2, color: "var(--dracula-red)", opacity: 0.16 },
{ min: 2, max: 3, color: "var(--dracula-orange)", opacity: 0.18 },
{ min: 3, max: 3.5, color: "var(--dracula-yellow)", opacity: 0.18 },
{ min: 3.5, max: 5, color: "var(--dracula-green)", opacity: 0.2 },
] as const;
const COLOR_MAIN = "#d6acff";
const COLOR_MA = "#ff79c6";
const COLOR_TREND = "#8be9fd";
// The mood line + overlays use the same hues the rest of the Insights
// charts do — purple primary (matching HealthChart), pink moving average,
// cyan trend — as tokens rather than the former hand-picked hex.
const COLOR_MAIN = "var(--dracula-purple)";
const COLOR_MA = "var(--dracula-pink)";
const COLOR_TREND = "var(--dracula-cyan)";
// --- Helpers ---
@@ -6,16 +6,16 @@ import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.8.0 → v1.8.5 — `<MetricTargetSummary>` 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("<MetricTargetSummary>", () => {
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 `<TileHeader>`;
// 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 `<MetricPrimaryTile>`,
// 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("<MetricTargetSummary>", () => {
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("<MetricTargetSummary>", () => {
...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("<MetricTargetSummary>", () => {
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("<MetricTargetSummary>", () => {
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", () => {
@@ -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: <T,>(token: DerivedBatchToken) => resolve(token) as unknown as DerivedMetricResponse<T>,
});
read: <T,>(token: DerivedBatchToken) =>
resolve(token) as unknown as DerivedMetricResponse<T>,
} as unknown as DashboardDerived;
}
beforeEach(() => vi.clearAllMocks());
describe("<VitalsDashboard>", () => {
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(<VitalsDashboard />);
const html = render(<VitalsDashboard batch={batch} />);
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("<VitalsDashboard>", () => {
});
it("hides the whole section (heading + grid) when no vital has content", () => {
mockBatch(() => insufficient("no_readings_in_window"));
const html = render(<VitalsDashboard />);
const batch = mockBatch(() => insufficient("no_readings_in_window"));
const html = render(<VitalsDashboard batch={batch} />);
// 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("<VitalsDashboard>", () => {
});
it("renders a skeleton row (no tiles, no heading strand) while loading", () => {
mockBatch(() => insufficient("no_readings_in_window"), { isLoading: true });
const html = render(<VitalsDashboard />);
const batch = mockBatch(() => insufficient("no_readings_in_window"), { isLoading: true });
const html = render(<VitalsDashboard batch={batch} />);
// 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("<VitalsDashboard>", () => {
});
it("shows an inline error + retry when the batch fails", () => {
mockBatch(() => insufficient("no_readings_in_window"), { isError: true });
const html = render(<VitalsDashboard />);
const batch = mockBatch(() => insufficient("no_readings_in_window"), { isError: true });
const html = render(<VitalsDashboard batch={batch} />);
// 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("<VitalsDashboard>", () => {
});
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(<VitalsDashboard />);
const html = render(<VitalsDashboard batch={batch} />);
expect(html).toContain('data-metric="WEIGHT"');
expect(html).toContain('data-state="provisional"');
expect(html).toContain('data-slot="coverage-meter"');
@@ -141,40 +133,40 @@ describe("<VitalsDashboard>", () => {
});
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(<VitalsDashboard />);
const html = render(<VitalsDashboard batch={batch} />);
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(<VitalsDashboard />);
const html = render(<VitalsDashboard batch={batch} />);
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(<VitalsDashboard />);
const html = render(<VitalsDashboard batch={batch} />);
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("<VitalsDashboard>", () => {
}
return insufficient("no_readings_in_window");
});
const html = render(<VitalsDashboard />);
const html = render(<VitalsDashboard batch={batch} />);
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("<VitalsDashboard>", () => {
}
return insufficient("no_readings_in_window");
});
const html = render(<VitalsDashboard />);
const html = render(<VitalsDashboard batch={batch} />);
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(<VitalsDashboard />);
const html = render(<VitalsDashboard batch={batch} />);
expect(html).toContain('data-slot="vitals-mobility"');
expect(html).toContain('data-metric="STAIR_ASCENT_SPEED_BASELINE"');
expect(html).toContain("Mobility &amp; body");
});
it("hides the whole Mobility & body section when no mobility metric has content", () => {
mockBatch(() => insufficient("no_readings_in_window"));
const html = render(<VitalsDashboard />);
const batch = mockBatch(() => insufficient("no_readings_in_window"));
const html = render(<VitalsDashboard batch={batch} />);
expect(html).not.toContain('data-slot="vitals-mobility"');
expect(html).not.toContain("Mobility &amp; body");
expect(html).not.toContain('data-metric="SIX_MINUTE_WALK_BAND"');
@@ -241,11 +233,11 @@ describe("<VitalsDashboard>", () => {
// 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(<VitalsDashboard />);
const html = render(<VitalsDashboard batch={batch} />);
expect(requestedCoincident).toBe(false);
expect(html).not.toContain('data-metric="COINCIDENT_DEVIATION"');
});
@@ -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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
type Resp = DerivedMetricResponse<Record<string, unknown>>;
function ok(value: Record<string, unknown>): 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<string, Resp>): <T>(
token: DerivedBatchToken,
) => DerivedMetricResponse<T> | null {
return <T,>(token: DerivedBatchToken) =>
(map[token.metric] ?? absent()) as unknown as DerivedMetricResponse<T>;
}
describe("<WellnessScores>", () => {
it("un-mounts the whole strip when no score has data", () => {
const html = render(
<WellnessScores read={readFrom({})} isLoading={false} />,
);
expect(html).toBe("");
});
it("renders nothing while loading (the page reserves the row)", () => {
const html = render(
<WellnessScores
read={readFrom({ READINESS: ok({ score: 80, band: "green" }) })}
isLoading
/>,
);
// 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(
<WellnessScores
read={readFrom({ READINESS: ok({ score: 82, band: "green" }) })}
isLoading={false}
/>,
);
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(
<WellnessScores
read={readFrom({
READINESS: ok({ score: 80, band: "green" }),
SLEEP_SCORE: ok({ score: 55, band: "yellow" }),
})}
isLoading={false}
/>,
);
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"');
});
});
+10 -7
View File
@@ -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 : "—"}
@@ -125,9 +125,17 @@ export function SparklineDeltaTile({
className,
)}
>
<div className="flex h-5 min-w-0 items-center justify-between gap-2">
{/* 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. */}
<div className="flex min-w-0 items-center gap-2">
<Icon
className="text-foreground h-5 w-5 shrink-0"
aria-hidden="true"
/>
<span
className="text-muted-foreground min-w-0 flex-1 truncate text-xs leading-5 font-medium tracking-wide whitespace-nowrap uppercase"
className="text-foreground min-w-0 flex-1 truncate text-base leading-none font-semibold whitespace-nowrap"
data-slot="sparkline-delta-tile-label"
>
{label}
@@ -135,12 +143,11 @@ export function SparklineDeltaTile({
{provenance ? (
<span
data-slot="sparkline-delta-tile-provenance"
className="shrink-0"
className="ml-auto shrink-0"
>
{provenance}
</span>
) : null}
<Icon className="text-muted-foreground h-4 w-4 shrink-0" />
</div>
<div
@@ -0,0 +1,88 @@
"use client";
import { useMemo } from "react";
import {
useDerivedBatch,
type DerivedBatchToken,
} from "./use-derived-metric";
// Type-only — keeps the derived registry's server graph out of the bundle.
import type { DerivedMetricId } from "@/lib/insights/derived/registry";
/**
* v1.12.6 — the shared derived-metric batch for the Insights overview.
*
* The overview paints the wellness-score strip and the vitals grid as two
* separate, full-width sections (wellness lifted above the daily briefing,
* vitals below it). Both surfaces read the same already-computed derived
* values, so this hook owns the ONE batched `/api/insights/derived/batch`
* request and hands its `read`/`isLoading`/`isError`/`refetch` handle to
* both. A single cache entry, one network round-trip — never a per-section
* fan-out (the v1.9.1 "hangs then recovers" symptom).
*
* The page mounts this once and passes the handle down, so the wellness
* strip and the vitals dashboard share the same query instance.
*/
/** The per-vital baseline tiles the dashboard renders (HRV has its own tile). */
export const SECTION_VITALS: string[] = [
"RESTING_HEART_RATE",
"HEART_RATE_VARIABILITY",
"RESPIRATORY_RATE",
"OXYGEN_SATURATION",
"BODY_TEMPERATURE",
"BLOOD_GLUCOSE",
"WEIGHT",
];
/**
* The any-user HealthKit baseline bands (Mobility section). The `metric` is
* the batch token + provenance key; `type` drives icon/label/unit.
*/
export const SECTION_MOBILITY: { metric: DerivedMetricId; 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" },
];
/**
* The full set of tokens the overview 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 mobility/body metrics. The
* coincident-deviation flag is read by the dedicated "Today's signal" card,
* not here.
*/
export 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;
}
/** The shared batch handle both the wellness strip and the vitals grid read. */
export type DashboardDerived = ReturnType<typeof useDerivedBatch>;
/**
* 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 });
}
@@ -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"
>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground truncate text-xs font-medium tracking-wide uppercase">
{label}
</span>
<Icon className="text-muted-foreground h-4 w-4 shrink-0" />
</div>
<TileHeader icon={Icon} title={label} titleClassName="truncate" />
<p className="text-muted-foreground text-sm" data-slot="vitals-tile-building">
{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"
>
<div className="flex items-center justify-between gap-2">
<span className="text-muted-foreground truncate text-xs font-medium tracking-wide uppercase">
{t("measurements.typeHeartRateVariability")}
</span>
<HeartPulse className="text-muted-foreground h-4 w-4 shrink-0" />
</div>
<TileHeader
icon={HeartPulse}
title={t("measurements.typeHeartRateVariability")}
titleClassName="truncate"
/>
<p className="text-muted-foreground text-sm">
{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"
>
<h2 className="text-foreground text-sm font-semibold tracking-tight">
{t("insights.derived.vitals.sectionTitle")}
</h2>
<TileHeader
icon={HeartPulse}
title={t("insights.derived.vitals.sectionTitle")}
/>
<div
data-slot="vitals-dashboard-error"
role="alert"
@@ -618,16 +562,16 @@ export function VitalsDashboard({ enabled = true, className }: DashboardProps) {
return (
<div data-slot="vitals-dashboard-wrap" className={cn("space-y-6", className)}>
<WellnessScores read={read} isLoading={isLoading} />
{showSection && (
<section
data-slot="vitals-dashboard"
aria-label={t("insights.derived.vitals.sectionTitle")}
className="space-y-3"
>
<h2 className="text-foreground text-sm font-semibold tracking-tight">
{t("insights.derived.vitals.sectionTitle")}
</h2>
<TileHeader
icon={HeartPulse}
title={t("insights.derived.vitals.sectionTitle")}
/>
<div
data-slot="vitals-dashboard-grid"
aria-busy={isLoading}
@@ -668,9 +612,10 @@ export function VitalsDashboard({ enabled = true, className }: DashboardProps) {
aria-label={t("insights.derived.vitals.mobilitySectionTitle")}
className="space-y-3"
>
<h2 className="text-foreground text-sm font-semibold tracking-tight">
{t("insights.derived.vitals.mobilitySectionTitle")}
</h2>
<TileHeader
icon={Footprints}
title={t("insights.derived.vitals.mobilitySectionTitle")}
/>
<div
data-slot="vitals-mobility-grid"
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
@@ -1,8 +1,12 @@
"use client";
import type { ComponentType } from "react";
import Link from "next/link";
import { Activity, Flame, Gauge, HeartPulse, Moon, RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { TileHeader } from "@/components/insights/tile-header";
import { ScoreRing } from "./score-ring";
import type { DerivedBatchRead } from "./use-derived-metric";
import type { ScoreBand } from "./band-tokens";
@@ -33,6 +37,15 @@ interface ScoreStripProps {
/** The batched-read selector from the parent dashboard's one query. */
read: DerivedBatchRead;
isLoading: boolean;
/**
* The shared derived-batch error flag. On a failed batch the strip renders
* a compact error + Retry in place instead of vanishing silently (the strip
* is now top-of-page, so its only Retry must live here, not far below in the
* Vitals slot).
*/
isError?: boolean;
/** Refetch the one shared batch query. Recovers both strips at once. */
refetch?: () => 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 (
<Link
href={href}
data-slot="wellness-score-tile"
data-metric={metricSlot}
className="bg-card border-border hover:border-foreground/20 focus-visible:ring-ring flex items-center justify-center rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
className="bg-card border-border hover:border-foreground/20 focus-visible:ring-ring flex flex-col gap-3 rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<div className="flex min-w-0 flex-col items-center gap-1.5">
{/* 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. */}
<TileHeader icon={icon} title={label} titleClassName="truncate" />
<div className="flex flex-col items-center gap-1.5">
{/* 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`). */}
<ScoreRing score={score} band={band} size="sm" />
<span
data-slot="wellness-score-label"
className="text-foreground max-w-full text-center text-xs font-medium text-balance"
>
{label}
</span>
<span
data-slot="wellness-score-band-word"
className="text-muted-foreground text-center text-xs"
@@ -84,9 +96,51 @@ function RingTile({
);
}
export function WellnessScores({ read, isLoading, className }: ScoreStripProps) {
export function WellnessScores({
read,
isLoading,
isError = false,
refetch,
className,
}: ScoreStripProps) {
const { t } = useTranslations();
// A failed shared batch must read as an error, not as "no scores" — the
// strip is top-of-page, so the only Retry lives here (mirrors the Vitals
// error card styling; one Retry recovers both strips).
if (isError) {
return (
<section
data-slot="wellness-scores"
aria-label={t("insights.derived.scores.sectionTitle")}
className={cn("space-y-3", className)}
>
<TileHeader
icon={Activity}
title={t("insights.derived.scores.sectionTitle")}
/>
<div
data-slot="wellness-scores-error"
role="alert"
className="bg-card border-border text-muted-foreground flex flex-col items-start gap-3 rounded-xl border p-4 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("insights.derived.scores.loadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => refetch?.()}
data-slot="wellness-scores-retry"
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
</div>
</section>
);
}
const readiness = read<ReadinessValue>({ metric: "READINESS" });
const sleep = read<SleepScoreValue>({ metric: "SLEEP_SCORE" });
const recovery = read<WellnessScoreValue>({ 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)}
>
<h2 className="text-foreground text-sm font-semibold tracking-tight">
{t("insights.derived.scores.sectionTitle")}
</h2>
<TileHeader
icon={Activity}
title={t("insights.derived.scores.sectionTitle")}
/>
<div
data-slot="wellness-scores-grid"
className={cn("grid grid-cols-2 gap-3 sm:grid-cols-3", lgCols)}
@@ -1,7 +1,7 @@
"use client";
import Link from "next/link";
import type { ComponentProps, ReactNode } from "react";
import type { ComponentProps, ComponentType, ReactNode } from "react";
import { Sparkles } from "lucide-react";
@@ -77,6 +77,14 @@ export interface HealthKitMetricPageProps {
emptyStateCtaType?: string | null;
/** Icon node mounted in the empty-state card. */
emptyStateIcon: ReactNode;
/**
* v1.12.6 — leading glyph for the stat strip's `<TileHeader>` (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 `<CoachLaunchButton>`'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={<MetricStatStrip summary={summary} unit={yAxisUnit ?? unit} />}
statStrip={
<MetricStatStrip
summary={summary}
unit={yAxisUnit ?? unit}
seriesLabel={title}
icon={statIcon}
/>
}
diversityNudge={
<MeasurementDiversityNudge
measurementType={measurementType}
+24 -18
View File
@@ -2,7 +2,8 @@
import { useEffect, useRef, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } 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";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
@@ -11,6 +12,22 @@ import { useFeatureFlags } from "@/hooks/use-feature-flags";
// ─── Types ────────────────────────────────────────────────
/**
* v1.12.6 — route the icon + title row through the canonical
* `<TileHeader>`. Callers still pass a pre-sized icon NODE (e.g.
* `<HeartPulse className="h-5 w-5" />`) because the bespoke metric pages
* own that prop; `<TileHeader>` 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 `<TileHeader>` 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"
>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
{icon}
<CardTitle className="text-base">{title}</CardTitle>
</div>
<TileHeader icon={nodeIcon(icon)} title={title} />
</CardHeader>
<CardContent className="space-y-2">
<div className="bg-muted h-3.5 w-full animate-pulse rounded motion-reduce:animate-none" />
@@ -115,10 +129,7 @@ export function InsightStatusCard({
return (
<Card className="gap-2 py-4 opacity-60 md:gap-3 md:py-5">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
{icon}
<CardTitle className="text-base">{title}</CardTitle>
</div>
<TileHeader icon={nodeIcon(icon)} title={title} />
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
@@ -133,10 +144,7 @@ export function InsightStatusCard({
return (
<Card className="gap-2 py-4 md:gap-3 md:py-5">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
{icon}
<CardTitle className="text-base">{title}</CardTitle>
</div>
<TileHeader icon={nodeIcon(icon)} title={title} />
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
@@ -175,11 +183,9 @@ export function InsightStatusCard({
<CardHeader className="pb-2">
{/* 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. */}
<div className="flex items-center gap-2">
{icon}
<CardTitle className="text-base">{title}</CardTitle>
</div>
still consumes the warm cache; it just no longer announces it.
v1.12.6 — the icon + title row is the canonical `<TileHeader>`. */}
<TileHeader icon={nodeIcon(icon)} title={title} />
</CardHeader>
<CardContent className="space-y-2">
{/* v1.4.27 — defence-in-depth strip. Cached status text from
@@ -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 ? (
<p
data-slot="insights-disclaimer-footer"
className="text-muted-foreground border-border/60 border-t pt-4 text-center text-xs leading-relaxed"
>
{t("insights.disclaimer.footer")}
</p>
) : null}
</div>
);
}
+53 -29
View File
@@ -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
* `<TileHeader>` (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 `<TileHeader>`. 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 (
<section
// Card-wrapped so the header-to-body offset matches the `<CardHeader
// pb-2>` 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`.
<Card
data-slot="metric-stat-strip"
role="group"
aria-label={
seriesLabel
? `${t("insights.subPage.stats.label")} — ${seriesLabel}`
: t("insights.subPage.stats.label")
}
className="bg-card border-border space-y-2 rounded-xl border p-4"
className="gap-2 py-4 md:gap-3 md:py-5"
>
{seriesLabel ? (
<p
data-slot="metric-stat-strip-series"
className="text-muted-foreground text-[10px] font-medium tracking-wide uppercase"
>
{seriesLabel}
</p>
<CardHeader className="pb-2">
<TileHeader icon={icon ?? Sigma} title={seriesLabel} />
</CardHeader>
) : null}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 [&>div]:min-h-[56px]">
{cells.map((cell) => (
<div
key={cell.key}
data-slot="metric-stat"
data-stat={cell.key}
className="space-y-1"
>
<p className="text-muted-foreground text-[10px] tracking-wide uppercase">
{cell.label}
</p>
<p className="text-lg font-semibold tabular-nums">
{format(cell.value)}
</p>
<CardContent>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 [&>div]:min-h-[56px]">
{cells.map((cell) => (
<div
key={cell.key}
data-slot="metric-stat"
data-stat={cell.key}
className="space-y-1"
>
<p className="text-muted-foreground text-[10px] tracking-wide uppercase">
{cell.label}
</p>
<p className="text-lg font-semibold tabular-nums">
{format(cell.value)}
</p>
</div>
))}
</div>
))}
</div>
</section>
</CardContent>
</Card>
);
}
@@ -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 (
<div
// Card-wrapped so the header-to-body offset matches the `<CardHeader
// pb-2>` 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.
<Card
data-slot="metric-target-summary"
data-target-type={target.type}
className="bg-card border-border space-y-3 rounded-xl border p-4"
className="gap-2 py-4 md:gap-3 md:py-5"
>
{/* Row 1: target range string + status pill + source link. */}
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5">
<div className="flex items-center gap-2.5">
<Target
className="text-dracula-green size-4 shrink-0"
aria-hidden="true"
/>
<div className="space-y-0.5">
{heading ? (
<p className="text-muted-foreground text-[0.6875rem] font-medium tracking-[0.06em] uppercase">
{heading}
</p>
) : null}
<p className="text-sm font-medium">{rangeLabel}</p>
</div>
</div>
<div className="flex items-center gap-2">
{target.classification ? (
<TargetStatusPill
classification={target.classification}
range={range}
unit={unit}
source={target.source}
/>
) : null}
</div>
</div>
{/* 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. */}
<CardHeader className="pb-2">
<TileHeader
icon={Target}
title={t("insights.target.heading")}
right={
target.classification ? (
<TargetStatusPill
classification={target.classification}
range={range}
unit={unit}
source={target.source}
/>
) : null
}
/>
{heading ? (
<p className="text-muted-foreground text-[0.6875rem] font-medium tracking-[0.06em] uppercase">
{heading}
</p>
) : null}
</CardHeader>
<CardContent className="space-y-3">
{/* Row 2: range bar — where today's value sits inside the band. */}
{target.current != null ? (
<RangeBar
value={target.current}
@@ -419,7 +400,8 @@ function TargetReferencePanel({
{t("targets.sourceLabel", { source: target.source })}
</span>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
}
@@ -80,12 +80,14 @@ describe("<MoodBetterDays>", () => {
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(
<MoodBetterDays factors={[tagFactor, metricFactor]} />,
);
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,
@@ -79,10 +79,12 @@ describe("<MoodTagInfluence>", () => {
expect(html).not.toContain("charts.weekdaysFull.mon");
});
it("renders the observational disclaimer once", () => {
it("does not render a local generic disclaimer", () => {
const html = render(
<MoodTagInfluence rows={[flatRow(), flatRow({ tag: "social" })]} />,
);
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");
});
});
@@ -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({
);
})}
</ul>
<p className="text-muted-foreground mt-3 text-[11px]">
{t("insights.mood.betterDays.disclaimer")}
</p>
</div>
);
}
@@ -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 (
<div className="space-y-2" data-slot="mood-discovered-relations">
<div className="flex items-center gap-1.5">
<h3 className="text-base font-semibold">
{t("insights.mood.discovery.title")}
</h3>
<Card data-slot="mood-discovered-relations">
<CardHeader className="pb-2">
{/* 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. */}
<MoodExplainerIcon
label={t("insights.mood.discovery.explainerLabel")}
detail={`${t("insights.mood.discovery.footer", {
tested: data.pairsTested,
})} ${t("insights.mood.discovery.disclaimer")}`}
instead of a low-density block under the list. v1.12.6 — the
heading now rides the canonical `TileHeader`, with the explainer
icon pinned to its trailing slot. */}
<TileHeader
icon={Compass}
title={t("insights.mood.discovery.title")}
right={
<MoodExplainerIcon
label={t("insights.mood.discovery.explainerLabel")}
detail={`${t("insights.mood.discovery.footer", {
tested: data.pairsTested,
})} ${t("insights.mood.discovery.disclaimer")}`}
/>
}
/>
</div>
<p className="text-muted-foreground text-sm">
{t("insights.mood.discovery.description")}
</p>
<ul className="divide-border divide-y">
</CardHeader>
<CardContent>
<p className="text-muted-foreground mb-2 text-sm">
{t("insights.mood.discovery.description")}
</p>
<ul className="divide-border divide-y">
{moodPairs.map((pair) => {
const moodIsOutcome = pair.outcome === "MOOD";
const channelKey = moodIsOutcome ? pair.behaviour : pair.outcome;
@@ -139,7 +148,8 @@ export function MoodDiscoveredRelations() {
</li>
);
})}
</ul>
</div>
</ul>
</CardContent>
</Card>
);
}
@@ -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 (
<Card>
<CardContent className="flex items-center gap-4 py-4">
<div className="bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center rounded-full">
<Target className="size-5" />
</div>
<div className="min-w-0">
<p className="text-foreground text-2xl font-semibold tabular-nums">
{t("insights.mood.inTargetValue", { pct: String(rounded) })}
</p>
<p className="text-muted-foreground text-sm">
{t("insights.mood.inTargetCaption")}
</p>
</div>
<CardHeader className="pb-2">
<TileHeader icon={Target} title={t("insights.mood.inTargetTitle")} />
</CardHeader>
<CardContent>
<p className="text-foreground text-2xl font-semibold tabular-nums">
{t("insights.mood.inTargetValue", { pct: String(rounded) })}
</p>
<p className="text-muted-foreground text-sm">
{t("insights.mood.inTargetCaption")}
</p>
</CardContent>
</Card>
);
@@ -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 (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">{title}</CardTitle>
<TileHeader icon={icon} title={title} />
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
@@ -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.
<div className="space-y-4">
{/* 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() {
</div>
)}
<MoodNarrativeFeed items={narratives} />
{/* "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 && (
<SectionCard title={t("insights.mood.betterDays.title")}>
<SectionCard
title={t("insights.mood.betterDays.title")}
icon={Sparkles}
>
<MoodBetterDays factors={betterDays} />
</SectionCard>
)}
<SectionCard title={t("insights.mood.heatmapTitle")}>
{narratives.length > 0 && (
<SectionCard title={t("insights.mood.narrative.title")} icon={TrendingUp}>
<MoodNarrativeFeed items={narratives} />
</SectionCard>
)}
<SectionCard title={t("insights.mood.heatmapTitle")} icon={CalendarDays}>
<MoodHeatmap
cells={heatmapCells}
days={data.heatmap.windowDays}
@@ -246,49 +282,58 @@ export function MoodInsightsSections() {
</SectionCard>
<div className="grid gap-4 lg:grid-cols-2">
<SectionCard title={t("insights.mood.distributionTitle")}>
<SectionCard
title={t("insights.mood.distributionTitle")}
icon={BarChart3}
>
<MoodDistributionChart distribution={data.distribution} />
</SectionCard>
<SectionCard title={t("insights.mood.weekdayTitle")}>
<SectionCard
title={t("insights.mood.weekdayTitle")}
icon={CalendarRange}
>
<MoodWeekdayChart weekday={data.weekday} />
</SectionCard>
</div>
{data.timeOfDay.reliable && (
<SectionCard title={t("insights.mood.timeOfDay.title")}>
<SectionCard title={t("insights.mood.timeOfDay.title")} icon={Clock}>
<MoodTimeOfDayChart pattern={data.timeOfDay} />
</SectionCard>
)}
{hasStructuredTags && (
<SectionCard title={t("insights.mood.structuredTagsTitle")}>
<SectionCard
title={t("insights.mood.structuredTagsTitle")}
icon={Tags}
>
<MoodStructuredTagBreakdown tags={data.structuredTags} />
</SectionCard>
)}
{hasTags && (
<SectionCard title={t("insights.mood.tagsTitle")}>
<SectionCard title={t("insights.mood.tagsTitle")} icon={Tag}>
<MoodTagBreakdown tags={data.tags} />
</SectionCard>
)}
{hasInfluence && (
<SectionCard title={t("insights.mood.influence.title")}>
<SectionCard title={t("insights.mood.influence.title")} icon={TrendingUp}>
<MoodTagInfluence rows={influenceRows} />
</SectionCard>
)}
{hasCrosstab && (
<SectionCard title={t("insights.mood.crosstab.title")}>
<SectionCard title={t("insights.mood.crosstab.title")} icon={Grid3x3}>
<MoodTagMetricCrosstab rows={crosstabRows} />
</SectionCard>
)}
<div className="space-y-2">
<h3 className="text-base font-semibold">
{t("insights.mood.correlationsTitle")}
</h3>
<p className="text-muted-foreground text-sm">
<SectionCard
title={t("insights.mood.correlationsTitle")}
icon={Link2}
>
<p className="text-muted-foreground mb-2 text-sm">
{t("insights.mood.correlationsDescription")}
</p>
<MoodCorrelationCards
@@ -298,7 +343,7 @@ export function MoodInsightsSections() {
weight={data.correlations.weight}
bloodPressureSystolic={data.correlations.bloodPressureSystolic}
/>
</div>
</SectionCard>
{/* F3 — the FDR-controlled discovered mood relations. Self-fetches the
correlation-discovery surface and renders nothing when the operator
@@ -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 (
<Card>
<CardContent className="flex items-center gap-4 py-4">
<div className="bg-muted text-muted-foreground flex size-10 shrink-0 items-center justify-center rounded-full">
<Activity className="size-5" />
</div>
<div className="min-w-0">
<p className="text-foreground text-2xl font-semibold tabular-nums">
{t("insights.mood.stability.value", {
score: String(stability.score),
})}
</p>
<p className="text-muted-foreground text-sm">
{t(BAND_KEY[stability.band])}
</p>
</div>
<CardHeader className="pb-2">
<TileHeader
icon={Activity}
title={t("insights.mood.stability.title")}
/>
</CardHeader>
<CardContent>
<p className="text-foreground text-2xl font-semibold tabular-nums">
{t("insights.mood.stability.value", {
score: String(stability.score),
})}
</p>
<p className="text-muted-foreground text-sm">
{t(BAND_KEY[stability.band])}
</p>
</CardContent>
</Card>
);
@@ -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({
);
})}
</ul>
<p className="text-muted-foreground mt-3 text-[11px]">
{t("insights.mood.influence.disclaimer")}
</p>
</div>
);
}
@@ -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({
);
})}
</ul>
<p className="text-muted-foreground mt-3 text-[11px]">
{t("insights.mood.crosstab.disclaimer")}
</p>
</div>
);
}
+21 -13
View File
@@ -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 `<MetricStatStrip metric=… />`; 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 `<MetricStatStrip metric=… />`; 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({
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</header>
{/* 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 `<HealthChart>` (also driving the dashboard) has
no Recharts `<Brush>` 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
@@ -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 (
<Card data-slot="therapy-timeline">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Syringe className="text-dracula-purple h-4 w-4" />
{t("insights.therapyTimeline.title")}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
{t("insights.therapyTimeline.empty")}
</p>
</CardContent>
</Card>
);
}
return (
<Card data-slot="therapy-timeline">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Syringe className="text-dracula-purple h-4 w-4" />
{t("insights.therapyTimeline.title")}
</CardTitle>
<p className="text-muted-foreground text-xs">
{t("insights.therapyTimeline.description")}
</p>
</CardHeader>
<CardContent>
<ol className="border-border/60 relative space-y-3 border-l pl-4">
{data.entries.map((entry) => (
<li
key={`${entry.kind}-${entry.date}-${entry.medicationName ?? ""}`}
className="relative"
>
<span
className="border-background absolute top-1 -left-[18px] h-3 w-3 rounded-full border-2"
aria-hidden="true"
style={{ backgroundColor: colourForKind(entry.kind) }}
/>
<div className="space-y-0.5">
{/* v1.4.27 B7 / L4 — sr-only drug-name heading per
entry so screen-reader users can jump between
medication blocks; the visible row keeps the
inline <strong> rendering.
v1.4.33 IW9 — promoted from h4 -> h2 so the page
outline stays sequential. The parent SubPageShell
ships an <h1>; CardTitle is a <div>, so the timeline
is the only heading-bearing surface here and h2
nests directly under h1 without skipping levels. */}
{entry.medicationName && (
<h2 className="sr-only">{entry.medicationName}</h2>
)}
<p className="text-muted-foreground text-xs">
{fmt.dateWithWeekday(new Date(entry.date))}
</p>
<p className="text-foreground/90 text-sm">
{renderEntryLine(entry, t)}
</p>
{entry.note && (
<p className="text-muted-foreground text-xs italic">
{entry.note}
</p>
)}
</div>
</li>
))}
</ol>
</CardContent>
</Card>
);
}
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, string | number>) => string,
): React.ReactNode {
const drug = entry.medicationName ?? "";
switch (entry.kind) {
case "dose-change": {
const arrow =
entry.doseDelta === "up" ? (
<ArrowUp className="inline h-3 w-3" aria-hidden="true" />
) : entry.doseDelta === "down" ? (
<ArrowDown className="inline h-3 w-3" aria-hidden="true" />
) : null;
return (
<span>
<strong>{drug}</strong> {t("insights.therapyTimeline.doseTo")}{" "}
<span className="font-medium tabular-nums">
{entry.doseValue} {entry.doseUnit}
</span>
{arrow && <> {arrow}</>}
</span>
);
}
case "injection": {
const site = entry.injectionSite
? ` · ${t(describeInjectionSite(entry.injectionSite))}`
: "";
return (
<span>
<Pill
className="text-dracula-cyan mr-1 inline h-3 w-3"
aria-hidden="true"
/>
<strong>{drug}</strong>
{site}
</span>
);
}
case "inventory": {
const sign = entry.inventoryDelta && entry.inventoryDelta > 0 ? "+" : "";
return (
<span>
<strong>{drug}</strong> · {sign}
{entry.inventoryDelta} {t("insights.therapyTimeline.pens")} (
{entry.reason})
</span>
);
}
case "side-effect": {
return (
<span>
{t("insights.therapyTimeline.sideEffects")}:{" "}
{(entry.tags ?? []).join(", ")}
</span>
);
}
}
}
+68
View File
@@ -0,0 +1,68 @@
import type { ComponentType, ReactNode } from "react";
import { CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/utils";
/**
* v1.12.6 — the one canonical tile header used by every Insights tile.
*
* Pre-v1.12.6 each tile hand-rolled its own `icon + heading` row at a
* slightly different size, weight, and colour: the "Einschätzung" card
* led with a foreground-colour icon + `CardTitle`, the target panel led
* with a small muted `Target` glyph + a range string, and the stat strip
* led with a tiny uppercase muted caption. Side-by-side on the same
* subpage they read as three different card languages.
*
* `TileHeader` pins the single header contract to the "Einschätzung"
* reference (the calmest, most legible of the three): a `flex
* items-center gap-2` row with the icon and the `CardTitle` BOTH in the
* foreground colour (white in dark mode) — never `text-muted-foreground`
* — at `text-base`, icon `h-5 w-5 shrink-0`. An optional `right` slot
* carries a trailing affordance (a provenance / tooltip glyph, a status
* pill) without breaking the row's alignment.
*
* RSC-safe: no hooks, no browser API, so it renders on the server inside
* any client or server tile.
*/
interface TileHeaderProps {
/**
* Leading glyph. Accepts a Lucide icon component (or any component that
* takes a `className`) so the caller passes the component itself
* (`icon={Target}`), not a pre-sized node — `TileHeader` owns the size
* and colour so every tile header matches.
*/
icon: ComponentType<{ className?: string }>;
/** Heading text (or node). Rendered inside a `CardTitle` at `text-base`. */
title: ReactNode;
/** Optional trailing affordance pinned to the right edge of the row. */
right?: ReactNode;
className?: string;
titleClassName?: string;
/** Optional id on the `CardTitle`, e.g. for an `aria-labelledby` link. */
id?: string;
}
export function TileHeader({
icon: Icon,
title,
right,
className,
titleClassName,
id,
}: TileHeaderProps) {
return (
<div
data-slot="tile-header"
className={cn("flex items-center gap-2", className)}
>
{/* Icon and heading share the foreground colour — the calm,
high-contrast read the "Einschätzung" card established. */}
<Icon className="text-foreground h-5 w-5 shrink-0" aria-hidden="true" />
<CardTitle id={id} className={cn("text-base", titleClassName)}>
{title}
</CardTitle>
{right ? <div className="ml-auto flex items-center">{right}</div> : null}
</div>
);
}