mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #263 from MBombeck/release/v1.12.6
release/v1.12.6 — one consistent Insights surface
This commit is contained in:
44 files changed
+1127
-793
No files matched your search
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 & 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 & 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"');
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user