From 1eb143fad4e50670c9967fd55fd4c6274fdbe135 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 15:15:01 +0200
Subject: [PATCH 01/71] fix(a11y): raise sub-44px tap targets to the mobile
floor
Bring interactive elements that fell below the 44px iOS/WCAG touch
floor up to min-h-11 on phones (relaxing to the denser sm density on
larger screens), and add visible focus rings where they were missing:
- TimesOfDayChips remove button + API-token copy buttons gain an
inset hit-area so the visual density stays tight while the tap
target clears the floor.
- Dose-history row-actions menu trigger and the insights suggested-
prompt chips raise to min-h-11 sm:min-h-9.
- Sortable table headers and the glucose advanced disclosure toggle
gain a focus-visible ring and a phone tap floor.
---
src/components/data-list/sortable-head.tsx | 2 +-
.../insights/glucose/glucose-advanced-disclosure.tsx | 2 +-
src/components/insights/suggested-prompts.tsx | 12 ++++++------
src/components/medications/dose-history-ledger.tsx | 2 +-
.../medications/scheduling/TimesOfDayChips.tsx | 2 +-
.../medications/sections/api-tokens-row.tsx | 4 ++--
6 files changed, 12 insertions(+), 12 deletions(-)
diff --git a/src/components/data-list/sortable-head.tsx b/src/components/data-list/sortable-head.tsx
index c02c0ac12..44730b408 100644
--- a/src/components/data-list/sortable-head.tsx
+++ b/src/components/data-list/sortable-head.tsx
@@ -32,7 +32,7 @@ export function SortableHead({
+ {/* v1.18.6 — surface the AI Insights / Coach / briefing setup at
+ the highest-intent moment. The flagship feature needs a BYOK or
+ local provider that the rest of onboarding never mentions, so a
+ fresh user otherwise discovers it as a cold error. This card is
+ skippable by construction — it is an optional deep-link, not a
+ gate. */}
+
+
+
+
+
+ {t("onboarding.done.aiTitle")}
+
+
+ {t("onboarding.done.aiBody")}
+
+
+ {t("onboarding.done.aiCta")}
+
+
+
Date: Thu, 18 Jun 2026 19:11:58 +0200
Subject: [PATCH 29/71] feat(insights): state Rest Mode on the health-score
card
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
During an active illness episode the server suppresses (never penalises)
the health score, but the card gave no sign of it, so a held number read
as a silent decline. The card now carries an explicit 'paused during
illness — not being judged today' line when Rest Mode is active, driven
from the value-free annotation already on the analytics payload. Adds
insights.healthScore.restModePaused across all six locales.
---
messages/de.json | 1 +
messages/en.json | 1 +
messages/es.json | 1 +
messages/fr.json | 1 +
messages/it.json | 1 +
messages/pl.json | 1 +
src/components/insights/health-score-card.tsx | 27 ++++++++++++++++++-
src/components/insights/hero-strip.tsx | 7 +++++
8 files changed, 39 insertions(+), 1 deletion(-)
diff --git a/messages/de.json b/messages/de.json
index ecf2d8338..ff302577a 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -1966,6 +1966,7 @@
"progressAria": "Gesundheitsscore-Fortschritt",
"deltaVsLastWeek": "{delta} im Vergleich zur Vorwoche",
"deltaUnavailable": "Noch keine Historie — schau nächste Woche wieder vorbei.",
+ "restModePaused": "Während einer Krankheitsphase pausiert — dein Score wird heute nicht bewertet.",
"componentBp": "Blutdruck",
"componentWeight": "Gewicht",
"componentMood": "Stimmung",
diff --git a/messages/en.json b/messages/en.json
index 63d8a76e0..5b51a4331 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1966,6 +1966,7 @@
"progressAria": "Health Score progress",
"deltaVsLastWeek": "{delta} vs last week",
"deltaUnavailable": "No history yet — check back next week.",
+ "restModePaused": "Paused during illness — your score isn't being judged today.",
"componentBp": "BP",
"componentWeight": "Weight",
"componentMood": "Mood",
diff --git a/messages/es.json b/messages/es.json
index fe84dfe60..af96478e9 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -1966,6 +1966,7 @@
"progressAria": "Progreso de la puntuación de salud",
"deltaVsLastWeek": "{delta} respecto a la semana anterior",
"deltaUnavailable": "Aún no hay historial — vuelve la semana que viene.",
+ "restModePaused": "En pausa durante la enfermedad — hoy tu puntuación no se evalúa.",
"componentBp": "PA",
"componentWeight": "Peso",
"componentMood": "Estado de ánimo",
diff --git a/messages/fr.json b/messages/fr.json
index fd10e6d92..217d9afe0 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -1966,6 +1966,7 @@
"progressAria": "Progression du score de santé",
"deltaVsLastWeek": "{delta} par rapport à la semaine précédente",
"deltaUnavailable": "Pas encore d’historique — reviens la semaine prochaine.",
+ "restModePaused": "En pause pendant la maladie — ton score n'est pas évalué aujourd'hui.",
"componentBp": "TA",
"componentWeight": "Poids",
"componentMood": "Humeur",
diff --git a/messages/it.json b/messages/it.json
index 841d260b7..abb275f53 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -1966,6 +1966,7 @@
"progressAria": "Avanzamento del punteggio di salute",
"deltaVsLastWeek": "{delta} rispetto alla settimana scorsa",
"deltaUnavailable": "Ancora nessuno storico — ripassa la prossima settimana.",
+ "restModePaused": "In pausa durante la malattia — oggi il tuo punteggio non viene valutato.",
"componentBp": "PA",
"componentWeight": "Peso",
"componentMood": "Umore",
diff --git a/messages/pl.json b/messages/pl.json
index 29520337d..77c41a83d 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -1966,6 +1966,7 @@
"progressAria": "Postęp wyniku zdrowia",
"deltaVsLastWeek": "{delta} w porównaniu z ubiegłym tygodniem",
"deltaUnavailable": "Brak historii — zajrzyj w przyszłym tygodniu.",
+ "restModePaused": "Wstrzymane podczas choroby — Twój wynik nie jest dziś oceniany.",
"componentBp": "RR",
"componentWeight": "Waga",
"componentMood": "Nastrój",
diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx
index ee2091d43..985454608 100644
--- a/src/components/insights/health-score-card.tsx
+++ b/src/components/insights/health-score-card.tsx
@@ -1,7 +1,7 @@
"use client";
import { useId, useMemo, useState } from "react";
-import { ArrowDown, ArrowUp, ChevronDown, Minus } from "lucide-react";
+import { ArrowDown, ArrowUp, ChevronDown, Minus, Moon } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer";
@@ -87,6 +87,15 @@ export interface HealthScoreCardProps {
* four-row layout for the common (mood-enabled) case.
*/
moodEnabled?: boolean;
+ /**
+ * v1.18.6 — Rest Mode annotation. When an illness episode is active the
+ * server suppresses (never penalises) the score; the card itself then
+ * carries an explicit "paused during illness — not being judged today"
+ * line so a frozen/held number reads as intentional rather than as a
+ * silent drop. Value-free: the card only needs to know that it is
+ * active, not the episode details. Default off (the common case).
+ */
+ restModeActive?: boolean;
}
type ComponentKey = keyof HealthScoreCardProps["components"];
@@ -174,6 +183,7 @@ export function HealthScoreCard({
onAskCoach: _onAskCoach,
initiallyExpanded = false,
moodEnabled = true,
+ restModeActive = false,
}: HealthScoreCardProps) {
const { t, locale } = useTranslations();
// The asOf timestamps render under the source pill as a tooltip
@@ -404,6 +414,21 @@ export function HealthScoreCard({
)}
+ {/* v1.18.6 — Rest Mode legibility. While an illness episode is
+ active the server suppresses (never penalises) the score, so
+ the card states plainly that the number is paused and not being
+ judged today. Without this the held score reads as a silent
+ decline. */}
+ {restModeActive && (
+
+
+ {t("insights.healthScore.restModePaused")}
+
+ )}
+
Date: Thu, 18 Jun 2026 19:15:51 +0200
Subject: [PATCH 30/71] feat(settings,measurements): restore module-hub intro
and clarify list/insights split
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The module hub lost its intro line in v1.18.1, so a new user could not
tell that the page drives the navigation, tiles and assessments they
see. Restore the section intro and the toggleable-modules hint (both
strings already in the bundle). Reframe the measurements subtitle from a
metric list to its role — log + full history here, trends and analysis
under Insights — so the two homes for 'look at my data' read as distinct
rather than redundant.
---
messages/de.json | 2 +-
messages/en.json | 2 +-
messages/es.json | 2 +-
messages/fr.json | 2 +-
messages/it.json | 2 +-
messages/pl.json | 2 +-
src/components/settings/modules-section.tsx | 16 ++++++++++++----
7 files changed, 18 insertions(+), 10 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index ff302577a..6f1f0195d 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -414,7 +414,7 @@
},
"measurements": {
"title": "Messungen",
- "subtitle": "Gewicht, Blutdruck, Puls und mehr",
+ "subtitle": "Werte erfassen und die vollständige Historie einsehen. Trends und Auswertungen findest du unter Auswertungen.",
"addMeasurement": "Hinzufügen",
"editMeasurement": "Messwert bearbeiten",
"type": "Typ",
diff --git a/messages/en.json b/messages/en.json
index 5b51a4331..22cd11dae 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -414,7 +414,7 @@
},
"measurements": {
"title": "Measurements",
- "subtitle": "Weight, blood pressure, pulse and more",
+ "subtitle": "Log readings and browse the full history. Trends and analysis live under Insights.",
"addMeasurement": "Add",
"editMeasurement": "Edit measurement",
"type": "Type",
diff --git a/messages/es.json b/messages/es.json
index af96478e9..eeda31fe0 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -414,7 +414,7 @@
},
"measurements": {
"title": "Mediciones",
- "subtitle": "Peso, tensión arterial, pulso y más",
+ "subtitle": "Registra lecturas y consulta el historial completo. Las tendencias y los análisis están en Análisis.",
"addMeasurement": "Añadir",
"editMeasurement": "Editar medición",
"type": "Tipo",
diff --git a/messages/fr.json b/messages/fr.json
index 217d9afe0..91ec14d05 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -414,7 +414,7 @@
},
"measurements": {
"title": "Mesures",
- "subtitle": "Poids, tension artérielle, pouls et plus",
+ "subtitle": "Saisis tes mesures et consulte l'historique complet. Les tendances et analyses sont dans Analyses.",
"addMeasurement": "Ajouter",
"editMeasurement": "Modifier la mesure",
"type": "Type",
diff --git a/messages/it.json b/messages/it.json
index abb275f53..83ca7fdb9 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -414,7 +414,7 @@
},
"measurements": {
"title": "Misurazioni",
- "subtitle": "Peso, pressione arteriosa, pulsazioni e altro",
+ "subtitle": "Registra le misurazioni e consulta lo storico completo. Tendenze e analisi sono in Analisi.",
"addMeasurement": "Aggiungi",
"editMeasurement": "Modifica misurazione",
"type": "Tipo",
diff --git a/messages/pl.json b/messages/pl.json
index 77c41a83d..4916f3dc7 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -414,7 +414,7 @@
},
"measurements": {
"title": "Pomiary",
- "subtitle": "Waga, ciśnienie tętnicze, tętno i więcej",
+ "subtitle": "Zapisuj pomiary i przeglądaj pełną historię. Trendy i analizy znajdziesz w Analizach.",
"addMeasurement": "Dodaj",
"editMeasurement": "Edytuj pomiar",
"type": "Typ",
diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx
index 95f1ae25b..f90fff8f7 100644
--- a/src/components/settings/modules-section.tsx
+++ b/src/components/settings/modules-section.tsx
@@ -118,21 +118,29 @@ export function ModulesSection() {
aria-labelledby="settings-section-modules-title"
className="space-y-6"
>
- {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
-
+ {/* v1.18.6 — restore a short intro line so a new user understands
+ that this page drives the nav, tiles and assessments they see.
+ (Dropped in v1.18.1 D0 for top alignment; the orientation cost
+ outweighed the alignment.) */}
+
{t("settings.sections.modules.title")}
+
+ {t("settings.sections.modules.description")}
+
- {/* Toggleable modules. v1.18.1 (D2) — the explanatory line above the
- first card dropped; the per-row labels carry the meaning. */}
+ {/* v1.18.6 — restore the one-line "what this section does" hint. */}
+
{(Object.keys(MODULE_REGISTRY) as ModuleKey[]).map((key) => {
const def = MODULE_REGISTRY[key];
From e0d4d809dd40d071bc44811506474f592ea3c4fe Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 19:17:36 +0200
Subject: [PATCH 31/71] feat(glucose): cross-link the clinical panel to Labs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Glucose has three front doors — measurements/CGM as readings over time,
the clinical panel as derived TIR/GMI/estimated-A1c, and Labs as dated
panel results — with no signpost between them. The clinical panel now
carries a one-line cross-link clarifying that a blood-draw HbA1c or
fasting glucose belongs in Labs, so the spot-estimate view and the
lab-result view reference each other instead of competing. Adds
insights.bloodGlucose.clinical.labsLink across all six locales.
---
messages/de.json | 1 +
messages/en.json | 1 +
messages/es.json | 1 +
messages/fr.json | 1 +
messages/it.json | 1 +
messages/pl.json | 1 +
.../glucose/glucose-clinical-panel.tsx | 29 ++++++++++++++++++-
7 files changed, 34 insertions(+), 1 deletion(-)
diff --git a/messages/de.json b/messages/de.json
index 6f1f0195d..21ee4c4f2 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2713,6 +2713,7 @@
"title": "Glukose-Überblick",
"subtitle": "Ein Blick auf deine letzten {days} Tage, aus {count} Messungen.",
"spotCaveat": "Das sind Schätzungen aus einzelnen Stichproben-Messungen, kein Profil eines kontinuierlichen Sensors – lies sie als Richtung, nicht als klinisches Urteil.",
+ "labsLink": "Laborergebnisse? HbA1c und Nüchternglukose aus einer Blutabnahme trägst du unter Labor ein.",
"learningTitle": "Lerne deine Glukose noch kennen",
"learningBody": "Bisher sind das {count} über {days}. Mit etwas mehr Verlauf wird das Bild klarer – mess weiter oder lass es ein verbundenes Gerät ergänzen.",
"learningBodyEmpty": "Sobald ein paar Messungen vorliegen – von dir erfasst oder von einem Gerät synchronisiert – nehmen Zeit-im-Zielbereich, Durchschnitt und Schwankung hier Gestalt an.",
diff --git a/messages/en.json b/messages/en.json
index 22cd11dae..e91951fe9 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2713,6 +2713,7 @@
"title": "Glucose overview",
"subtitle": "A read on your last {days} days, from {count} readings.",
"spotCaveat": "These are spot-reading estimates from individual measurements, not a continuous-monitor profile — read them as a direction of travel rather than a clinical verdict.",
+ "labsLink": "Lab results? Log a blood-draw HbA1c or fasting glucose under Labs.",
"learningTitle": "Still learning your glucose",
"learningBody": "So far that's {count} over {days}. A little more history and a fuller picture comes into focus — keep logging, or let a connected device fill it in.",
"learningBodyEmpty": "Once a handful of readings come in — logged by you or synced from a device — your time-in-range, average, and variability take shape here.",
diff --git a/messages/es.json b/messages/es.json
index eeda31fe0..6f2928108 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2713,6 +2713,7 @@
"title": "Resumen de glucosa",
"subtitle": "Una lectura de tus últimos {days} días, a partir de {count} mediciones.",
"spotCaveat": "Son estimaciones a partir de mediciones puntuales, no un perfil de un sensor continuo: léelas como una tendencia, no como un veredicto clínico.",
+ "labsLink": "¿Resultados de laboratorio? Registra una HbA1c o glucosa en ayunas de una analítica en Laboratorio.",
"learningTitle": "Aún conociendo tu glucosa",
"learningBody": "De momento son {count} en {days}. Con algo más de historial el panorama se aclara: sigue registrando o deja que lo complete un dispositivo conectado.",
"learningBodyEmpty": "En cuanto lleguen unas cuantas mediciones —registradas por ti o sincronizadas desde un dispositivo— tu tiempo en rango, promedio y variabilidad tomarán forma aquí.",
diff --git a/messages/fr.json b/messages/fr.json
index 91ec14d05..d2a4076fe 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2713,6 +2713,7 @@
"title": "Aperçu du glucose",
"subtitle": "Un point sur vos {days} derniers jours, à partir de {count} mesures.",
"spotCaveat": "Ce sont des estimations issues de mesures ponctuelles, pas le profil d’un capteur continu : lisez-les comme une tendance, non comme un verdict clinique.",
+ "labsLink": "Résultats de laboratoire ? Saisis une HbA1c ou une glycémie à jeun issue d'une prise de sang dans Laboratoire.",
"learningTitle": "Votre glucose se découvre encore",
"learningBody": "Pour l’instant, cela fait {count} sur {days}. Avec un peu plus d’historique, le tableau se précise : continuez à enregistrer ou laissez un appareil connecté compléter.",
"learningBodyEmpty": "Dès que quelques mesures arrivent — saisies par vous ou synchronisées depuis un appareil — votre temps dans la cible, votre moyenne et votre variabilité prennent forme ici.",
diff --git a/messages/it.json b/messages/it.json
index 83ca7fdb9..8873b3ec7 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2713,6 +2713,7 @@
"title": "Panoramica della glicemia",
"subtitle": "Una lettura dei tuoi ultimi {days} giorni, da {count} misurazioni.",
"spotCaveat": "Sono stime da singole misurazioni puntuali, non il profilo di un sensore continuo: leggile come una tendenza, non come un verdetto clinico.",
+ "labsLink": "Risultati di laboratorio? Registra una HbA1c o la glicemia a digiuno da un prelievo in Laboratorio.",
"learningTitle": "Stiamo ancora imparando la tua glicemia",
"learningBody": "Per ora sono {count} su {days}. Con un po’ più di storico il quadro si chiarisce: continua a registrare o lascia che lo completi un dispositivo connesso.",
"learningBodyEmpty": "Appena arrivano alcune misurazioni — registrate da te o sincronizzate da un dispositivo — il tempo nel range, la media e la variabilità prendono forma qui.",
diff --git a/messages/pl.json b/messages/pl.json
index 4916f3dc7..0f4eb78f3 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2713,6 +2713,7 @@
"title": "Przegląd glukozy",
"subtitle": "Spojrzenie na ostatnie {days} dni, na podstawie {count} pomiarów.",
"spotCaveat": "To szacunki z pojedynczych pomiarów punktowych, a nie profil z czujnika ciągłego — traktuj je jako kierunek, nie jako diagnozę.",
+ "labsLink": "Wyniki laboratoryjne? HbA1c lub glukozę na czczo z pobrania krwi zapisz w sekcji Laboratorium.",
"learningTitle": "Wciąż poznajemy Twoją glukozę",
"learningBody": "Na razie to {count} w ciągu {days}. Przy nieco dłuższej historii obraz się wyostrza — rejestruj dalej lub pozwól uzupełnić to podłączonemu urządzeniu.",
"learningBodyEmpty": "Gdy pojawi się kilka pomiarów — wpisanych przez Ciebie lub zsynchronizowanych z urządzenia — czas w zakresie, średnia i zmienność nabiorą tu kształtu.",
diff --git a/src/components/insights/glucose/glucose-clinical-panel.tsx b/src/components/insights/glucose/glucose-clinical-panel.tsx
index 144447eff..3e233c593 100644
--- a/src/components/insights/glucose/glucose-clinical-panel.tsx
+++ b/src/components/insights/glucose/glucose-clinical-panel.tsx
@@ -1,6 +1,7 @@
"use client";
-import { Activity, Droplet, TrendingUp } from "lucide-react";
+import Link from "next/link";
+import { Activity, Droplet, FlaskConical, TrendingUp } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -113,6 +114,9 @@ export function GlucoseClinicalPanel() {
}
caveat={t("insights.bloodGlucose.clinical.spotCaveat")}
/>
+
+
+
);
@@ -225,11 +229,34 @@ export function GlucoseClinicalPanel() {
byContext={byContext}
/>
) : null}
+
+
);
}
+/**
+ * v1.18.6 — signpost the measurement-vs-lab mental model. This panel
+ * estimates A1c / GMI from spot or CGM readings over time; a lab-ordered
+ * HbA1c or fasting-glucose result is a dated panel value that lives under
+ * Labs. The cross-link keeps the two surfaces referencing each other so a
+ * user knows which door to use rather than treating them as rivals.
+ */
+function LabsCrossLink() {
+ const { t } = useTranslations();
+ return (
+
+
+ {t("insights.bloodGlucose.clinical.labsLink")}
+
+ );
+}
+
function Metric({
icon,
label,
From 59e7b332858523f93f6bcb021d59a105e129c63f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 19:27:28 +0200
Subject: [PATCH 32/71] feat(onboarding): add resumable module-tour progress
field + API
Add nullable onboarding_tour_progress_json to users (migration 0173),
a Zod-validated progress shape, and extend POST /api/onboarding/tour to
accept a mid-tour resume checkpoint and clear it on replay. Surface the
resume point on GET /api/auth/me and register the contract in OpenAPI so
the native client mirrors it.
---
docs/api/openapi.yaml | 139 ++++++++++++++++++
.../0173_v1186_tour_progress/migration.sql | 22 +++
prisma/schema.prisma | 16 ++
src/app/api/auth/me/route.ts | 6 +
src/app/api/onboarding/tour/route.ts | 67 ++++++---
.../__tests__/disable-coach-card.test.tsx | 1 +
.../__tests__/modules-section.test.tsx | 1 +
.../__tests__/time-format-select.test.tsx | 1 +
.../__tests__/unit-preference-select.test.tsx | 1 +
src/hooks/use-auth.ts | 17 +++
.../__tests__/coach-user-disable.test.tsx | 1 +
.../__tests__/tour-progress.test.ts | 51 +++++++
src/lib/onboarding/tour-progress.ts | 53 +++++++
src/lib/openapi/routes/index.ts | 2 +
src/lib/openapi/routes/onboarding.ts | 81 ++++++++++
15 files changed, 442 insertions(+), 17 deletions(-)
create mode 100644 prisma/migrations/0173_v1186_tour_progress/migration.sql
create mode 100644 src/lib/onboarding/__tests__/tour-progress.test.ts
create mode 100644 src/lib/onboarding/tour-progress.ts
create mode 100644 src/lib/openapi/routes/onboarding.ts
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 6868782b2..b55ba0e06 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -6749,6 +6749,30 @@ paths:
"401": *a1
"422": *a3
"429": *a4
+ /api/onboarding/tour:
+ post:
+ tags:
+ - Onboarding
+ summary: Update module-tour completion + resume point
+ description: Persists the module-tour state. The client posts a fire-and-forget `progress` checkpoint on each step so a
+ reload resumes at the right module, and a terminal `completed:true` with `outcome` when the tour ends.
+ `completed:false` is a replay reset that also clears the resume point.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TourUpdateRequest"
+ responses:
+ "200":
+ description: Tour state updated.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/TourUpdateEnvelope"
+ "401": *a1
+ "422": *a3
+ "429": *a4
components:
schemas:
PasskeyLoginVerifyRequest:
@@ -8079,6 +8103,55 @@ components:
type: string
required:
- ids
+ TourUpdateRequest:
+ type: object
+ properties:
+ completed:
+ description: Flip the coarse completion flag. `false` is a replay reset and clears the stored progress point.
+ type: boolean
+ outcome:
+ description: Informational — distinguishes reaching the end from a skip.
+ type: string
+ enum:
+ - completed
+ - skipped
+ progress:
+ description: Mid-tour resume checkpoint. May arrive alone or with `completed`.
+ $ref: "#/components/schemas/TourProgress"
+ description: Update the module-tour state. Provide `completed` and/or `progress`.
+ TourProgress:
+ type: object
+ properties:
+ lastStopId:
+ anyOf:
+ - type: string
+ minLength: 1
+ maxLength: 64
+ - type: "null"
+ completedStopIds:
+ default: []
+ maxItems: 64
+ type: array
+ items:
+ type: string
+ minLength: 1
+ maxLength: 64
+ status:
+ type: string
+ enum:
+ - in_progress
+ - completed
+ - skipped
+ updatedAt:
+ type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
+ required:
+ - lastStopId
+ - status
+ - updatedAt
+ description: Resumable module-tour progress point. `lastStopId` seeds the resume index; `status` is the running/terminal
+ state.
MedicationTreatmentClass:
type: string
enum:
@@ -19363,6 +19436,37 @@ components:
description: "Cross-episode retrospective summary over the trailing window: episode + resolved counts, the typical
(median) recovery gap (null below the min-sample floor — withholds a thin claim), a recurrence-by-month tally,
and a per-type breakdown. Retrospective only."
+ TourUpdateEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/TourUpdateResponse"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ TourUpdateResponse:
+ type: object
+ properties:
+ onboardingTourCompleted:
+ type: boolean
+ progress:
+ anyOf:
+ - $ref: "#/components/schemas/TourProgressOutput"
+ - type: "null"
+ required:
+ - onboardingTourCompleted
+ - progress
+ additionalProperties: false
+ description: The persisted completion flag and resume point after the write.
MedicationScheduleOutput:
type: object
properties:
@@ -19634,6 +19738,41 @@ components:
compatibility and are normalised to their English equivalents before persisting; GET responses always carry the
canonical English ids. Section ids are English-only. The legacy tile ids are deprecated and will be removed in a
future major version."
+ TourProgressOutput:
+ type: object
+ properties:
+ lastStopId:
+ anyOf:
+ - type: string
+ minLength: 1
+ maxLength: 64
+ - type: "null"
+ completedStopIds:
+ default: []
+ maxItems: 64
+ type: array
+ items:
+ type: string
+ minLength: 1
+ maxLength: 64
+ status:
+ type: string
+ enum:
+ - in_progress
+ - completed
+ - skipped
+ updatedAt:
+ type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
+ required:
+ - lastStopId
+ - completedStopIds
+ - status
+ - updatedAt
+ additionalProperties: false
+ description: Resumable module-tour progress point. `lastStopId` seeds the resume index; `status` is the running/terminal
+ state.
Medication:
type: object
properties:
diff --git a/prisma/migrations/0173_v1186_tour_progress/migration.sql b/prisma/migrations/0173_v1186_tour_progress/migration.sql
new file mode 100644
index 000000000..498a859f5
--- /dev/null
+++ b/prisma/migrations/0173_v1186_tour_progress/migration.sql
@@ -0,0 +1,22 @@
+-- v1.18.6 — resumable module-tour progress.
+--
+-- A single nullable JSONB column on `users` carrying the fine-grained
+-- resume point for the module-by-module guided tour. Follows the
+-- established per-user JSON-column convention (`dashboard_widgets_json`,
+-- `insights_layout_json`, `coach_prefs_json`).
+--
+-- Shape (validated by `src/lib/onboarding/tour-progress.ts`):
+-- { "lastStopId": string | null,
+-- "completedStopIds": string[],
+-- "status": "in_progress" | "completed" | "skipped",
+-- "updatedAt": string (ISO 8601) }
+--
+-- NULL = the user has not started the module tour. The coarse
+-- `onboarding_tour_completed` boolean stays the launcher's auto-launch
+-- gate; this column is purely the resume point and is cleared back to
+-- NULL on a Settings replay so the tour genuinely restarts.
+--
+-- Additive + nullable: every existing account keeps NULL and the
+-- application resolver treats NULL as "not started".
+
+ALTER TABLE "users" ADD COLUMN "onboarding_tour_progress_json" JSONB;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index a69270678..948d0db58 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -256,6 +256,22 @@ model User {
// Settings → Account.
onboardingTourCompleted Boolean @default(false) @map("onboarding_tour_completed")
+ // v1.18.6 — resumable module-tour progress. The coarse boolean above
+ // stays as the "did the user ever finish/dismiss" flag the launcher
+ // gates on; this column carries the fine-grained resume point so a
+ // mid-tour reload reopens at the right module. Follows the per-user
+ // JSON-column convention (`dashboardWidgetsJson` / `insightsLayoutJson`
+ // / `coachPrefsJson`). Shape (validated by
+ // `src/lib/onboarding/tour-progress.ts`):
+ // { lastStopId: string | null,
+ // completedStopIds: string[],
+ // status: "in_progress" | "completed" | "skipped",
+ // updatedAt: string (ISO) }
+ // Null = the user has not started the module tour (fresh account or a
+ // legacy account that only carries the boolean flag). Cleared back to
+ // null on a Settings replay so the tour genuinely restarts.
+ onboardingTourProgressJson Json? @map("onboarding_tour_progress_json")
+
// HealthKit (iOS) integration config — list of per-metric direction toggles.
// Shape: { entries: [{ id, kind, direction, enabled }], lastSyncedAt }
healthKitConfigJson Json? @map("healthkit_config_json")
diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts
index f7fec1a52..d23f63b8e 100644
--- a/src/app/api/auth/me/route.ts
+++ b/src/app/api/auth/me/route.ts
@@ -10,6 +10,7 @@ import {
resolveModuleMap,
getOperatorModuleAvailability,
} from "@/lib/modules/gate";
+import { parseTourProgress } from "@/lib/onboarding/tour-progress";
export const dynamic = "force-dynamic";
@@ -70,6 +71,11 @@ export const GET = apiHandler(async () => {
timezone: user.timezone,
onboardingCompletedAt: user.onboardingCompletedAt,
onboardingTourCompleted: user.onboardingTourCompleted,
+ // v1.18.6 — resumable module-tour progress. Null when the user has
+ // not started the tour; otherwise the resume point the launcher
+ // seeds its index from. Fail-soft parse: a corrupt blob degrades to
+ // null ("start from the top") rather than 500-ing the /me read.
+ onboardingTourProgress: parseTourProgress(user.onboardingTourProgressJson),
// v1.5.5 — self-hosted avatar. Replaces the Gravatar leak; the
// URL is relative so PWA + native clients render identically
// and the `?v={updatedAtMs}` suffix busts the browser cache on
diff --git a/src/app/api/onboarding/tour/route.ts b/src/app/api/onboarding/tour/route.ts
index 9e9b72a4a..8d88d408a 100644
--- a/src/app/api/onboarding/tour/route.ts
+++ b/src/app/api/onboarding/tour/route.ts
@@ -5,30 +5,45 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
+import { tourProgressSchema } from "@/lib/onboarding/tour-progress";
+import { Prisma } from "@/generated/prisma/client";
/**
* v1.4.15 Phase B5 — onboarding tour completion endpoint.
+ * v1.18.6 — extended with the resumable module-tour progress point.
*
- * Two operations on a single resource:
+ * Operations on a single resource:
*
* POST { completed: true, outcome: "completed" | "skipped" }
* → flips `users.onboarding_tour_completed` to true. Auto-fires
- * when the user reaches the end of the spotlight tour OR
- * explicitly dismisses it.
+ * when the user reaches the end of the module tour OR explicitly
+ * dismisses it.
*
* POST { completed: false }
* → resets the flag so the user can replay the tour from
- * Settings → Account → "Restart onboarding tour".
+ * Settings → Advanced → "Modul-Tour neu starten". Also CLEARS
+ * `onboarding_tour_progress_json` back to null so the replay
+ * genuinely starts from the first module.
*
- * The `outcome` field is informational — it lands in the structured
- * Wide Event annotation so analytics can tell completion from
- * dismiss without writing two columns. Same DB write either way.
+ * POST { progress: { lastStopId, completedStopIds, status, updatedAt } }
+ * → fire-and-forget mid-tour checkpoint so a reload resumes at the
+ * right module. May arrive WITH `completed` (the terminal
+ * checkpoint sends both) or alone (every `nextStep`).
+ *
+ * `outcome` is informational — it lands in the structured Wide Event
+ * annotation so analytics can tell completion from dismiss without a
+ * second column. Same DB write either way.
*/
-const tourBodySchema = z.object({
- completed: z.boolean(),
- outcome: z.enum(["completed", "skipped"]).optional(),
-});
+const tourBodySchema = z
+ .object({
+ completed: z.boolean().optional(),
+ outcome: z.enum(["completed", "skipped"]).optional(),
+ progress: tourProgressSchema.optional(),
+ })
+ .refine((b) => b.completed !== undefined || b.progress !== undefined, {
+ message: "Provide `completed` and/or `progress`.",
+ });
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -43,18 +58,36 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Invalid input", 422);
}
- await prisma.user.update({
- where: { id: user.id },
- data: { onboardingTourCompleted: parsed.data.completed },
- });
+ // Build the update field-by-field (no mass assignment). `completed`
+ // drives the coarse boolean; a replay (`completed:false`) wipes the
+ // resume point; a progress checkpoint writes the JSON column.
+ const data: Prisma.UserUpdateInput = {};
+ if (parsed.data.completed !== undefined) {
+ data.onboardingTourCompleted = parsed.data.completed;
+ if (parsed.data.completed === false) {
+ // Replay — start the resume point over.
+ data.onboardingTourProgressJson = Prisma.JsonNull;
+ }
+ }
+ if (parsed.data.progress !== undefined) {
+ data.onboardingTourProgressJson = parsed.data.progress;
+ }
+
+ await prisma.user.update({ where: { id: user.id }, data });
annotate({
action: { name: "onboarding.tour.update" },
meta: {
- completed: parsed.data.completed,
+ completed: parsed.data.completed ?? null,
outcome: parsed.data.outcome ?? null,
+ progressStatus: parsed.data.progress?.status ?? null,
+ lastStopId: parsed.data.progress?.lastStopId ?? null,
},
});
- return apiSuccess({ onboardingTourCompleted: parsed.data.completed });
+ return apiSuccess({
+ onboardingTourCompleted:
+ parsed.data.completed ?? user.onboardingTourCompleted,
+ progress: parsed.data.progress ?? null,
+ });
});
diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx
index 495fa9a59..aaf77d38b 100644
--- a/src/components/settings/__tests__/disable-coach-card.test.tsx
+++ b/src/components/settings/__tests__/disable-coach-card.test.tsx
@@ -63,6 +63,7 @@ function buildUser(disableCoach: boolean): AuthUser {
timezone: "Europe/Berlin",
onboardingCompletedAt: null,
onboardingTourCompleted: true,
+ onboardingTourProgress: null,
avatarUrl: null,
glucoseUnit: null,
unitPreference: "metric",
diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx
index af1183e3b..32d23c180 100644
--- a/src/components/settings/__tests__/modules-section.test.tsx
+++ b/src/components/settings/__tests__/modules-section.test.tsx
@@ -69,6 +69,7 @@ function buildUser(modules: AuthUser["modules"]): AuthUser {
timezone: "Europe/Berlin",
onboardingCompletedAt: null,
onboardingTourCompleted: true,
+ onboardingTourProgress: null,
avatarUrl: null,
glucoseUnit: null,
unitPreference: "metric",
diff --git a/src/components/settings/__tests__/time-format-select.test.tsx b/src/components/settings/__tests__/time-format-select.test.tsx
index 848d95017..2bb55ac6b 100644
--- a/src/components/settings/__tests__/time-format-select.test.tsx
+++ b/src/components/settings/__tests__/time-format-select.test.tsx
@@ -41,6 +41,7 @@ function buildUser(timeFormat: TimeFormatPreference): AuthUser {
timezone: "Europe/Berlin",
onboardingCompletedAt: null,
onboardingTourCompleted: true,
+ onboardingTourProgress: null,
avatarUrl: null,
glucoseUnit: null,
unitPreference: "metric",
diff --git a/src/components/settings/__tests__/unit-preference-select.test.tsx b/src/components/settings/__tests__/unit-preference-select.test.tsx
index 13430adf1..c5e0a3b13 100644
--- a/src/components/settings/__tests__/unit-preference-select.test.tsx
+++ b/src/components/settings/__tests__/unit-preference-select.test.tsx
@@ -44,6 +44,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser {
timezone: "Europe/Berlin",
onboardingCompletedAt: null,
onboardingTourCompleted: true,
+ onboardingTourProgress: null,
avatarUrl: null,
glucoseUnit: null,
unitPreference,
diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts
index ba079e0c7..4fcf04a7d 100644
--- a/src/hooks/use-auth.ts
+++ b/src/hooks/use-auth.ts
@@ -10,8 +10,15 @@ import { useTranslations } from "@/lib/i18n/context";
import type { TimeFormatPreference } from "@/lib/format-locale";
import { isTimeFormatPreference, storeTimeFormat } from "@/lib/time-format";
import type { ModuleKey } from "@/lib/modules/registry";
+import type { TourProgress } from "@/lib/onboarding/tour-progress";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
+/**
+ * v1.18.6 — resume point for the module tour as the client sees it on
+ * `/api/auth/me`. Mirrors the server-side `TourProgress` shape.
+ */
+export type AuthTourProgress = TourProgress;
+
export interface AuthUser {
id: string;
username: string;
@@ -29,6 +36,13 @@ export interface AuthUser {
* `` component for the gating logic.
*/
onboardingTourCompleted: boolean;
+ /**
+ * v1.18.6 — resumable module-tour progress, or null when the user
+ * has not started the tour. The `` seeds its resume
+ * index from `lastStopId`. Distinct from the coarse
+ * `onboardingTourCompleted` boolean, which stays the auto-launch gate.
+ */
+ onboardingTourProgress: AuthTourProgress | null;
/**
* v1.5.5 — relative URL of the user's self-hosted avatar, served
* from `/api/user/avatar/{id}?v={updatedAtMs}`. Replaces the
@@ -137,6 +151,9 @@ async function fetchMe(): Promise {
return {
...(data as AuthUser),
disableCoach: data.disableCoach ?? false,
+ // v1.18.6 — coerce against a stale /me payload (older server image
+ // without the field) to null so the tour starts from the top.
+ onboardingTourProgress: data.onboardingTourProgress ?? null,
// v1.7.0 — coerce against a stale /me payload (older server image
// without the field) so the display defaults to metric.
unitPreference: data.unitPreference === "imperial" ? "imperial" : "metric",
diff --git a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx
index 594a040ad..9120db140 100644
--- a/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx
+++ b/src/lib/feature-flags/__tests__/coach-user-disable.test.tsx
@@ -91,6 +91,7 @@ function buildUser(disableCoach: boolean): AuthUser {
timezone: "Europe/Berlin",
onboardingCompletedAt: null,
onboardingTourCompleted: true,
+ onboardingTourProgress: null,
avatarUrl: null,
glucoseUnit: null,
unitPreference: "metric",
diff --git a/src/lib/onboarding/__tests__/tour-progress.test.ts b/src/lib/onboarding/__tests__/tour-progress.test.ts
new file mode 100644
index 000000000..52a108e8d
--- /dev/null
+++ b/src/lib/onboarding/__tests__/tour-progress.test.ts
@@ -0,0 +1,51 @@
+import { describe, it, expect } from "vitest";
+
+import {
+ parseTourProgress,
+ tourProgressSchema,
+ type TourProgress,
+} from "@/lib/onboarding/tour-progress";
+
+const valid: TourProgress = {
+ lastStopId: "labs",
+ completedStopIds: ["dashboardOverview", "quickAdd"],
+ status: "in_progress",
+ updatedAt: "2026-06-18T10:00:00.000Z",
+};
+
+describe("tour-progress", () => {
+ it("parses a well-formed progress object", () => {
+ expect(parseTourProgress(valid)).toEqual(valid);
+ });
+
+ it("treats null / undefined as not-started", () => {
+ expect(parseTourProgress(null)).toBeNull();
+ expect(parseTourProgress(undefined)).toBeNull();
+ });
+
+ it("degrades a corrupt blob to null rather than throwing", () => {
+ expect(parseTourProgress({ status: "bogus" })).toBeNull();
+ expect(parseTourProgress({ lastStopId: 42 })).toBeNull();
+ expect(parseTourProgress("not-an-object")).toBeNull();
+ });
+
+ it("defaults completedStopIds to an empty array", () => {
+ const parsed = tourProgressSchema.parse({
+ lastStopId: null,
+ status: "skipped",
+ updatedAt: "2026-06-18T10:00:00.000Z",
+ });
+ expect(parsed.completedStopIds).toEqual([]);
+ });
+
+ it("accepts a null lastStopId (centred/wrap stop or fresh start)", () => {
+ expect(parseTourProgress({ ...valid, lastStopId: null })).not.toBeNull();
+ });
+
+ it("rejects an over-long completedStopIds array", () => {
+ const tooMany = Array.from({ length: 65 }, (_, i) => `s${i}`);
+ expect(
+ parseTourProgress({ ...valid, completedStopIds: tooMany }),
+ ).toBeNull();
+ });
+});
diff --git a/src/lib/onboarding/tour-progress.ts b/src/lib/onboarding/tour-progress.ts
new file mode 100644
index 000000000..b186ce7d3
--- /dev/null
+++ b/src/lib/onboarding/tour-progress.ts
@@ -0,0 +1,53 @@
+/**
+ * v1.18.6 — resumable module-tour progress.
+ *
+ * Pure data + Zod schema for the `users.onboarding_tour_progress_json`
+ * column. Kept DOM-free + free of Prisma imports so vitest (node env)
+ * and the OpenAPI registry can both consume it.
+ *
+ * The coarse `onboardingTourCompleted` boolean stays the launcher's
+ * auto-launch gate; this object is the fine-grained resume point so a
+ * mid-tour reload reopens at the right module. `null` = the user has
+ * not started the module tour.
+ */
+
+import { z } from "zod/v4";
+
+/** Terminal / running status of the module tour. */
+export const TOUR_PROGRESS_STATUSES = [
+ "in_progress",
+ "completed",
+ "skipped",
+] as const;
+
+export type TourProgressStatus = (typeof TOUR_PROGRESS_STATUSES)[number];
+
+/**
+ * The persisted progress shape. `lastStopId` seeds the resume index;
+ * `completedStopIds` is informational (analytics + future per-module
+ * "already seen" hints). Stop ids are bounded strings — the client
+ * sends a known stop id from `buildTourStops()`, but the server does
+ * not couple to that closed set (a future stop must not 422 an older
+ * client and vice-versa), so the field is a length-capped string array
+ * rather than a hard enum.
+ */
+export const tourProgressSchema = z.object({
+ lastStopId: z.string().min(1).max(64).nullable(),
+ completedStopIds: z.array(z.string().min(1).max(64)).max(64).default([]),
+ status: z.enum(TOUR_PROGRESS_STATUSES),
+ updatedAt: z.iso.datetime(),
+});
+
+export type TourProgress = z.infer;
+
+/**
+ * Parse a value read from the JSON column. Returns `null` for a null /
+ * absent column AND for any malformed payload — the resume point is a
+ * non-critical convenience, so a corrupt blob degrades to "start from
+ * the top" rather than throwing into the auth/me read path.
+ */
+export function parseTourProgress(value: unknown): TourProgress | null {
+ if (value == null) return null;
+ const parsed = tourProgressSchema.safeParse(value);
+ return parsed.success ? parsed.data : null;
+}
diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts
index dbab3937c..0ac6f7361 100644
--- a/src/lib/openapi/routes/index.ts
+++ b/src/lib/openapi/routes/index.ts
@@ -42,6 +42,7 @@ import { measurementReminderPaths } from "./measurement-reminders";
import { medicationPaths, medicationResource } from "./medications";
import { metaPaths } from "./meta";
import { moodPaths } from "./mood";
+import { onboardingPaths } from "./onboarding";
import { profilePaths } from "./profile";
import { settingsPaths } from "./settings";
import { syncPaths } from "./sync";
@@ -72,6 +73,7 @@ export const openApiPaths: NonNullable = {
...labsPaths,
...biomarkerPaths,
...illnessPaths,
+ ...onboardingPaths,
};
export const openApiComponents: NonNullable = {
diff --git a/src/lib/openapi/routes/onboarding.ts b/src/lib/openapi/routes/onboarding.ts
new file mode 100644
index 000000000..efdc1b4cf
--- /dev/null
+++ b/src/lib/openapi/routes/onboarding.ts
@@ -0,0 +1,81 @@
+/**
+ * OpenAPI route table — onboarding module tour.
+ *
+ * Part of the OpenAPI route table; aggregated in `./index.ts`.
+ * The request schema comes from `src/lib/onboarding/tour-progress.ts`
+ * so the wire contract stays single-source with the runtime parser.
+ *
+ * v1.18.6 — the resumable module-tour contract the iOS client mirrors:
+ * a fire-and-forget progress checkpoint plus the coarse completion
+ * flip. The resume point also rides `GET /api/auth/me` as
+ * `onboardingTourProgress`.
+ */
+import { z } from "zod/v4";
+import type { ZodOpenApiObject } from "zod-openapi";
+
+import { tourProgressSchema } from "@/lib/onboarding/tour-progress";
+import { dataEnvelope, stdResponses } from "./shared";
+
+const tourProgressResource = tourProgressSchema.meta({
+ id: "TourProgress",
+ description:
+ "Resumable module-tour progress point. `lastStopId` seeds the resume index; `status` is the running/terminal state.",
+});
+
+const tourUpdateRequest = z
+ .object({
+ completed: z
+ .boolean()
+ .optional()
+ .describe(
+ "Flip the coarse completion flag. `false` is a replay reset and clears the stored progress point.",
+ ),
+ outcome: z
+ .enum(["completed", "skipped"])
+ .optional()
+ .describe("Informational — distinguishes reaching the end from a skip."),
+ progress: tourProgressResource
+ .optional()
+ .describe("Mid-tour resume checkpoint. May arrive alone or with `completed`."),
+ })
+ .meta({
+ id: "TourUpdateRequest",
+ description:
+ "Update the module-tour state. Provide `completed` and/or `progress`.",
+ });
+
+const tourUpdateResponse = z
+ .object({
+ onboardingTourCompleted: z.boolean(),
+ progress: tourProgressResource.nullable(),
+ })
+ .meta({
+ id: "TourUpdateResponse",
+ description: "The persisted completion flag and resume point after the write.",
+ });
+
+export const onboardingPaths: NonNullable = {
+ "/api/onboarding/tour": {
+ post: {
+ tags: ["Onboarding"],
+ summary: "Update module-tour completion + resume point",
+ description:
+ "Persists the module-tour state. The client posts a fire-and-forget `progress` checkpoint on each step so a reload resumes at the right module, and a terminal `completed:true` with `outcome` when the tour ends. `completed:false` is a replay reset that also clears the resume point.",
+ requestBody: {
+ required: true,
+ content: { "application/json": { schema: tourUpdateRequest } },
+ },
+ responses: {
+ "200": {
+ description: "Tour state updated.",
+ content: {
+ "application/json": {
+ schema: dataEnvelope(tourUpdateResponse, "TourUpdateEnvelope"),
+ },
+ },
+ },
+ ...stdResponses,
+ },
+ },
+ },
+};
From f0c25cc275abc427808486255082e8047d8169bc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 19:39:10 +0200
Subject: [PATCH 33/71] feat(onboarding): rebuild the tour as a 15-stop
cross-page module walk
Replace the five chrome-pointer stops with a module-by-module guided tour
in the data->intelligence->export order. Each stop teaches the module's
value, one hidden capability, and how it feeds the others. Stops carry a
route + module gate; the overlay router.pushes to the route, waits for the
anchor to mount, then measures, so a single tour walks the whole product.
Move the launcher to the app shell so the overlay survives navigation,
auto-open immediately after anamnesis for fresh users (24h path stays the
fallback for wizard-skippers), resume from the persisted checkpoint, and
gate stops + the step counter on the resolved module map. Add tour copy
for all 15 stops across the six locales.
---
messages/de.json | 71 +++-
messages/en.json | 71 +++-
messages/es.json | 71 +++-
messages/fr.json | 71 +++-
messages/it.json | 71 +++-
messages/pl.json | 71 +++-
src/app/page.tsx | 14 +-
src/components/layout/auth-shell.tsx | 8 +
src/components/onboarding/DoneScreen.tsx | 14 +
.../onboarding/__tests__/tour.test.tsx | 47 +--
src/components/onboarding/tour-launcher.tsx | 137 +++++--
src/components/onboarding/tour.tsx | 158 ++++++--
.../onboarding/__tests__/tour-state.test.ts | 188 ++++++----
src/lib/onboarding/tour-state.ts | 353 +++++++++++++-----
14 files changed, 1008 insertions(+), 337 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index 0d6890d30..83131c9e2 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -3647,31 +3647,72 @@
"back": "Zurück",
"skip": "Tour überspringen",
"done": "Verstanden",
- "restart": "Tour neu starten",
- "restartHint": "Zeige die Dashboard-Tour beim nächsten Aufruf erneut.",
- "restartConfirmation": "Die Tour startet bei deinem nächsten Dashboard-Besuch erneut.",
+ "restart": "Modul-Tour neu starten",
+ "restartHint": "Zeigt die geführte Modul-Tour beim nächsten Dashboard-Besuch von vorne.",
+ "restartConfirmation": "Die Modul-Tour startet bei deinem nächsten Dashboard-Besuch von vorne.",
"steps": {
- "tileStrip": {
- "title": "Deine Tageswerte",
- "body": "Die Kachel-Leiste zeigt den letzten Wert und den 7-Tage-Trend für jede Metrik — Gewicht, Blutdruck, Puls, Stimmung. Was du nicht brauchst, blendest du in Einstellungen → Dashboard aus."
+ "dashboardOverview": {
+ "title": "Dein Dashboard",
+ "body": "Das Dashboard bündelt alle Module auf einen Blick. Halte eine Kachel lange gedrückt, um sie neu anzuordnen oder auszublenden (Einstellungen → Dashboard-Layout). Jede Kachel ist nur so aussagekräftig wie die Daten dahinter — die folgenden Module füllen sie."
},
"quickAdd": {
- "title": "In zwei Taps erfasst",
- "body": "Über den Hinzufügen-Button kannst du Messungen oder Stimmungseinträge anlegen, ohne das Dashboard zu verlassen. Das Formular merkt sich deinen letzten Kontext und Tags."
+ "title": "In Sekunden erfasst",
+ "body": "Über „+ Hinzufügen“ trägst du jeden Messwert in Sekunden ein — Gewicht, Blutdruck, Puls, Glukose. Versteckte Stärke: Werte lassen sich nachträglich datieren. Alles, was du hier einträgst, fließt in Trends, Health-Score und die KI-Insights."
+ },
+ "measurements": {
+ "title": "Messwerte & Zielbereiche",
+ "body": "Messwerte zeigt Verlauf und Zielbereiche pro Metrik. Hidden Gem: Zielbereiche pro Wert definieren, damit Abweichungen farblich auffallen. Diese Reihen sind die Grundlage für Korrelationen im Coach und den Arztbericht."
+ },
+ "medications": {
+ "title": "Medikamente & Einnahmen",
+ "body": "Medikamente verwaltet Zeitpläne, Einnahmen, Bestand und Titration. Versteckt: wiederkehrende Pläne (RRULE/rollierend) und Bestandswarnungen. Deine Einnahmequote speist die Compliance-Säule im Dashboard und den Kontext der KI."
+ },
+ "labs": {
+ "title": "Laborwerte",
+ "body": "Labore erfasst Biomarker mit Referenzbereichen — Blutbild, Cholesterin, HbA1c. Stärke: Import und FHIR-Export. Auffällige Laborwerte tauchen als Belege in den Insights und im Arztbericht wieder auf."
+ },
+ "illness": {
+ "title": "Krankheits-Journal",
+ "body": "Das Krankheits-Journal dokumentiert akute Episoden und chronische Diagnosen samt Schüben. Versteckt: die Korrelation einer Episode mit deinen Messwerten und der Ruhemodus, der Reminder pausiert. So versteht der Coach, warum Werte zeitweise abweichen."
+ },
+ "vorsorge": {
+ "title": "Vorsorge",
+ "body": "Vorsorge erinnert an Untersuchungen und Impfungen mit eigenem Intervall. Tipp: „Erledigt“ kann direkt eine Messung oder ein Laborergebnis anlegen. Fällige Vorsorgen erscheinen als Reminder und im Arztbericht."
+ },
+ "cycle": {
+ "title": "Zyklus",
+ "body": "Die Zyklus-Verfolgung protokolliert Phasen und Symptome. Versteckt: Symptome lassen sich Stimmung und Schlaf gegenüberstellen. Der Zyklus liefert Kontext für Schwankungen, die sonst unerklärlich wären."
+ },
+ "mood": {
+ "title": "Stimmung",
+ "body": "Stimmung erfasst dein Befinden und Tags in zwei Sekunden. Stärke: eigene Tags. Stimmungsverläufe korrelieren mit Schlaf, Medikamenten und Zyklus — der Coach erkennt Muster."
},
"insights": {
- "title": "Automatische Einblicke",
- "body": "Insights verwandelt deine Werte in eine klare Gesundheits-Zusammenfassung, mit Einordnung von Blutdruck, Gewicht und Stimmung. Eigener Provider-Key oder Standard — du entscheidest."
+ "title": "Insights",
+ "body": "Insights ist das Herzstück: belegbasierte, dynamische Auswertungen statt generischer Tipps — jede Aussage verweist auf deine echten Daten. Versteckt: das Layout ist anpassbar. Insights zieht alles zusammen, was die Module oben liefern."
+ },
+ "coach": {
+ "title": "Coach",
+ "body": "Der Coach beantwortet Fragen zu deinen Daten im Dialog und nutzt Anamnese, Medikamente und Laborwerte als Kontext. Den KI-Anbieter wählst du selbst (eigener Schlüssel oder lokal). Er rechnet nichts neu — er liest dieselben Zahlen wie das Dashboard."
},
"integrations": {
- "title": "Geräte verbinden",
- "body": "In den Einstellungen verbindest du Withings (Auto-Sync für Gewicht, Blutdruck, Puls), moodLog oder deinen Auswertungs-Provider. Alles bleibt optional und verschlüsselt."
+ "title": "Integrationen",
+ "body": "Integrationen synchronisieren Withings, WHOOP, Polar, Oura und Apple Health automatisch. So füllen sich Gewicht, Schlaf und Erholung ohne manuelles Tippen — und nähren wiederum Insights und Coach."
+ },
+ "export": {
+ "title": "Arztbericht",
+ "body": "Der Arztbericht bündelt Messwerte, Medikamente, Laborwerte und Vorsorge als PDF oder FHIR für den Termin. Hier laufen alle Module zusammen — ein vollständiger Bericht entsteht nur bei gepflegten Daten."
},
"achievements": {
- "title": "Streaks und Badges sammeln",
- "body": "Die Achievements-Seite trackt tägliche Logging-Streaks, Blutdruck-Meilensteine und Konsistenz-Abzeichen. Ein kleiner Anreiz, dranzubleiben."
+ "title": "Erfolge",
+ "body": "Erfolge belohnen Konsistenz — Streaks und Meilensteine. Ein kleiner Motivations-Loop, der das Dranbleiben leichter macht."
+ },
+ "wrapUp": {
+ "title": "Das war die Tour",
+ "body": "Du kannst jedes Modul jederzeit einzeln erneut ansehen („Diese Tour zeigen“ auf jeder Seite) oder die komplette Tour unter Einstellungen → Erweitert neu starten."
}
- }
+ },
+ "moduleTrigger": "Diese Tour zeigen"
},
"shell": {
"back": "Zurück",
diff --git a/messages/en.json b/messages/en.json
index 0895bb5d8..092fe42e9 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -3647,31 +3647,72 @@
"back": "Back",
"skip": "Skip tour",
"done": "Got it",
- "restart": "Restart onboarding tour",
- "restartHint": "Replay the dashboard walkthrough from the start.",
- "restartConfirmation": "Tour will restart on your next dashboard visit.",
+ "restart": "Restart module tour",
+ "restartHint": "Replays the guided module tour from the start on your next dashboard visit.",
+ "restartConfirmation": "The module tour will restart from the beginning on your next dashboard visit.",
"steps": {
- "tileStrip": {
- "title": "Your daily numbers",
- "body": "The tile strip shows the latest reading and a 7-day trend for every metric you log — weight, blood pressure, pulse, mood. Hide what you don't need from Settings → Dashboard."
+ "dashboardOverview": {
+ "title": "Your dashboard",
+ "body": "The dashboard pulls every module together at a glance. Long-press a tile to reorder or hide it (Settings → Dashboard layout). Each tile is only as telling as the data behind it — the modules ahead fill them."
},
"quickAdd": {
- "title": "Log anything in two taps",
- "body": "Use the Add button to drop a measurement or mood entry without leaving the dashboard. The form remembers your last context and tags."
+ "title": "Logged in seconds",
+ "body": "Use “+ Add” to log any reading in seconds — weight, blood pressure, pulse, glucose. Hidden strength: backdate readings you forgot to enter. Everything you log here feeds trends, the health score, and the AI insights."
+ },
+ "measurements": {
+ "title": "Readings & target ranges",
+ "body": "Measurements shows the history and target range for each metric. Hidden gem: set a target range per value so deviations stand out in colour. These series are the basis for the Coach’s correlations and the doctor report."
+ },
+ "medications": {
+ "title": "Medications & intakes",
+ "body": "Medications manages schedules, intakes, stock, and titration. Hidden: recurring plans (RRULE/rolling) and low-stock warnings. Your intake rate feeds the compliance pillar on the dashboard and the AI’s context."
+ },
+ "labs": {
+ "title": "Lab values",
+ "body": "Labs captures biomarkers with reference ranges — blood count, cholesterol, HbA1c. Strength: import and FHIR export. Out-of-range lab values resurface as evidence in the insights and the doctor report."
+ },
+ "illness": {
+ "title": "Illness journal",
+ "body": "The illness journal records acute episodes and chronic diagnoses with their flares. Hidden: correlate an episode with your readings, and Rest Mode that pauses reminders. That’s how the Coach understands why values drift for a while."
+ },
+ "vorsorge": {
+ "title": "Preventive care",
+ "body": "Preventive care reminds you of check-ups and vaccinations on their own interval. Tip: “Done” can create a reading or a lab result directly. Due items appear as reminders and in the doctor report."
+ },
+ "cycle": {
+ "title": "Cycle",
+ "body": "Cycle tracking logs phases and symptoms. Hidden: compare symptoms against mood and sleep. The cycle gives context for swings that would otherwise look unexplained."
+ },
+ "mood": {
+ "title": "Mood",
+ "body": "Mood captures how you feel plus tags in two seconds. Strength: your own tags. Mood trends correlate with sleep, medications, and cycle — the Coach spots the patterns."
},
"insights": {
- "title": "Automatic insights",
- "body": "Insights turns your readings into a plain-English health summary, with classifications for blood pressure, weight trend and mood. Bring your own provider key or use the built-in default."
+ "title": "Insights",
+ "body": "Insights is the heart of it: evidence-based, dynamic analysis instead of generic tips — every statement points at your real data. Hidden: the layout is customizable. Insights pulls together everything the modules above provide."
+ },
+ "coach": {
+ "title": "Coach",
+ "body": "The Coach answers questions about your data in a dialogue, using your history, medications, and lab values as context. Pick your AI provider yourself (your own key or local). It recomputes nothing — it reads the same numbers as the dashboard."
},
"integrations": {
- "title": "Connect your devices",
- "body": "Open Settings to link Withings (auto-sync weight, BP and pulse), moodLog, or your Insights provider. Everything stays optional and encrypted — and under Settings → Modules you can turn whole areas like sleep, mood or achievements on or off to keep HealthLog focused on what you track."
+ "title": "Integrations",
+ "body": "Integrations sync Withings, WHOOP, Polar, Oura, and Apple Health automatically. Weight, sleep, and recovery fill in without manual typing — and feed the insights and Coach in turn."
+ },
+ "export": {
+ "title": "Doctor report",
+ "body": "The doctor report bundles readings, medications, lab values, and preventive care as PDF or FHIR for the appointment. This is where every module converges — a complete report only comes from well-kept data."
},
"achievements": {
- "title": "Earn streaks and badges",
- "body": "The Achievements page tracks daily logging streaks, blood-pressure milestones and consistency badges. A small nudge to keep going."
+ "title": "Achievements",
+ "body": "Achievements reward consistency — streaks and milestones. A small motivation loop that makes sticking with it easier."
+ },
+ "wrapUp": {
+ "title": "That’s the tour",
+ "body": "You can revisit any module on its own at any time (“Show this tour” on every page) or restart the whole tour under Settings → Advanced."
}
- }
+ },
+ "moduleTrigger": "Show this tour"
},
"shell": {
"back": "Back",
diff --git a/messages/es.json b/messages/es.json
index ecc64c96c..2110b8cd6 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -3647,31 +3647,72 @@
"back": "Atrás",
"skip": "Saltar tour",
"done": "Entendido",
- "restart": "Reiniciar tour",
- "restartHint": "Vuelve a mostrar el tour del panel la próxima vez.",
- "restartConfirmation": "El tour se reiniciará en tu próxima visita al panel.",
+ "restart": "Reiniciar la visita de módulos",
+ "restartHint": "Vuelve a reproducir la visita guiada de módulos desde el principio en tu próxima visita al panel.",
+ "restartConfirmation": "La visita de módulos se reiniciará desde el principio en tu próxima visita al panel.",
"steps": {
- "tileStrip": {
- "title": "Tus valores del día",
- "body": "La fila de tarjetas muestra el último valor y la tendencia de 7 días de cada métrica — peso, tensión arterial, pulso, ánimo. Oculta lo que no necesites en Ajustes → Panel."
+ "dashboardOverview": {
+ "title": "Tu panel",
+ "body": "El panel reúne todos los módulos de un vistazo. Mantén pulsada una tarjeta para reordenarla u ocultarla (Ajustes → Diseño del panel). Cada tarjeta vale tanto como los datos que tiene detrás: los módulos siguientes los rellenan."
},
"quickAdd": {
- "title": "Registrado en dos toques",
- "body": "Con el botón Añadir puedes registrar mediciones o entradas de ánimo sin salir del panel. El formulario recuerda tu último contexto y etiquetas."
+ "title": "Registrado en segundos",
+ "body": "Con «+ Añadir» registras cualquier medición en segundos: peso, tensión, pulso, glucosa. Fuerza oculta: fechar valores olvidados. Todo lo que registras aquí alimenta las tendencias, la puntuación de salud y las ideas de IA."
+ },
+ "measurements": {
+ "title": "Mediciones y rangos objetivo",
+ "body": "Mediciones muestra el historial y el rango objetivo de cada métrica. Joya oculta: define un rango por valor para que las desviaciones resalten en color. Estas series son la base de las correlaciones del Coach y del informe médico."
+ },
+ "medications": {
+ "title": "Medicación y tomas",
+ "body": "Medicación gestiona horarios, tomas, existencias y titulación. Oculto: planes recurrentes (RRULE/rotativos) y avisos de poco stock. Tu tasa de tomas alimenta el pilar de cumplimiento del panel y el contexto de la IA."
+ },
+ "labs": {
+ "title": "Valores de laboratorio",
+ "body": "Laboratorio recoge biomarcadores con rangos de referencia: hemograma, colesterol, HbA1c. Fuerza: importación y exportación FHIR. Los valores fuera de rango reaparecen como pruebas en las ideas y en el informe médico."
+ },
+ "illness": {
+ "title": "Diario de enfermedades",
+ "body": "El diario de enfermedades registra episodios agudos y diagnósticos crónicos con sus brotes. Oculto: correlacionar un episodio con tus mediciones y el Modo descanso que pausa los recordatorios. Así el Coach entiende por qué los valores varían un tiempo."
+ },
+ "vorsorge": {
+ "title": "Prevención",
+ "body": "Prevención te recuerda revisiones y vacunas con su propio intervalo. Consejo: «Hecho» puede crear directamente una medición o un resultado de laboratorio. Lo pendiente aparece como recordatorio y en el informe médico."
+ },
+ "cycle": {
+ "title": "Ciclo",
+ "body": "El seguimiento del ciclo registra fases y síntomas. Oculto: comparar síntomas con el ánimo y el sueño. El ciclo aporta contexto a variaciones que de otro modo parecerían inexplicables."
+ },
+ "mood": {
+ "title": "Ánimo",
+ "body": "Ánimo capta cómo te sientes y etiquetas en dos segundos. Fuerza: tus propias etiquetas. Las tendencias de ánimo se correlacionan con el sueño, la medicación y el ciclo: el Coach detecta los patrones."
},
"insights": {
- "title": "Insights automáticos",
- "body": "Insights convierte tus valores en un resumen claro de salud, con interpretación de tensión arterial, peso y ánimo. Clave propia o por defecto — tú eliges."
+ "title": "Ideas",
+ "body": "Ideas es el corazón: análisis dinámico y basado en pruebas en lugar de consejos genéricos; cada afirmación apunta a tus datos reales. Oculto: el diseño es personalizable. Ideas reúne todo lo que aportan los módulos anteriores."
+ },
+ "coach": {
+ "title": "Coach",
+ "body": "El Coach responde preguntas sobre tus datos en diálogo, usando tu historial, medicación y valores de laboratorio como contexto. Tú eliges el proveedor de IA (tu propia clave o local). No recalcula nada: lee las mismas cifras que el panel."
},
"integrations": {
- "title": "Conectar dispositivos",
- "body": "En Ajustes conectas Withings (sincronización automática de peso, tensión y pulso), moodLog o tu proveedor de análisis. Todo es opcional y queda cifrado."
+ "title": "Integraciones",
+ "body": "Las integraciones sincronizan Withings, WHOOP, Polar, Oura y Apple Health automáticamente. Peso, sueño y recuperación se rellenan sin teclear, y a su vez alimentan las ideas y el Coach."
+ },
+ "export": {
+ "title": "Informe médico",
+ "body": "El informe médico reúne mediciones, medicación, valores de laboratorio y prevención en PDF o FHIR para la cita. Aquí convergen todos los módulos: un informe completo solo sale de datos bien cuidados."
},
"achievements": {
- "title": "Acumula rachas e insignias",
- "body": "La página de Logros lleva la cuenta de rachas de registro diario, hitos de tensión arterial e insignias de constancia. Un pequeño empujón para no abandonar."
+ "title": "Logros",
+ "body": "Los logros premian la constancia: rachas e hitos. Un pequeño bucle de motivación que facilita seguir adelante."
+ },
+ "wrapUp": {
+ "title": "Esto es la visita",
+ "body": "Puedes volver a ver cualquier módulo por separado cuando quieras («Mostrar esta visita» en cada página) o reiniciar la visita completa en Ajustes → Avanzado."
}
- }
+ },
+ "moduleTrigger": "Mostrar esta visita"
},
"shell": {
"back": "Atrás",
diff --git a/messages/fr.json b/messages/fr.json
index ea1705a4a..8f6e5a3d4 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -3647,31 +3647,72 @@
"back": "Précédent",
"skip": "Passer la visite",
"done": "Compris",
- "restart": "Relancer la visite",
- "restartHint": "Affiche à nouveau la visite du tableau de bord à la prochaine ouverture.",
- "restartConfirmation": "La visite redémarrera lors de ta prochaine visite du tableau de bord.",
+ "restart": "Relancer la visite des modules",
+ "restartHint": "Rejoue la visite guidée des modules depuis le début lors de votre prochaine ouverture du tableau de bord.",
+ "restartConfirmation": "La visite des modules redémarrera depuis le début à votre prochaine ouverture du tableau de bord.",
"steps": {
- "tileStrip": {
- "title": "Tes valeurs du jour",
- "body": "La barre de tuiles affiche la dernière valeur et la tendance 7 jours de chaque métrique — poids, tension, pouls, humeur. Masque ce qui ne te sert pas dans Paramètres → Tableau de bord."
+ "dashboardOverview": {
+ "title": "Votre tableau de bord",
+ "body": "Le tableau de bord rassemble tous les modules d’un coup d’œil. Appuyez longuement sur une tuile pour la réorganiser ou la masquer (Réglages → Disposition du tableau de bord). Une tuile ne vaut que par les données derrière elle — les modules suivants les remplissent."
},
"quickAdd": {
- "title": "Saisi en deux tapotements",
- "body": "Via le bouton Ajouter, tu saisis des mesures ou des humeurs sans quitter le tableau de bord. Le formulaire mémorise le dernier contexte et les étiquettes."
+ "title": "Saisi en quelques secondes",
+ "body": "Avec « + Ajouter », saisissez n’importe quelle mesure en quelques secondes — poids, tension, pouls, glycémie. Atout caché : antidater les valeurs oubliées. Tout ce que vous saisissez ici alimente les tendances, le score santé et les analyses IA."
+ },
+ "measurements": {
+ "title": "Mesures et plages cibles",
+ "body": "Mesures affiche l’historique et la plage cible de chaque métrique. Astuce cachée : définir une plage par valeur pour que les écarts ressortent en couleur. Ces séries sont la base des corrélations du Coach et du rapport médical."
+ },
+ "medications": {
+ "title": "Médicaments et prises",
+ "body": "Médicaments gère plannings, prises, stock et titration. Caché : plans récurrents (RRULE/glissants) et alertes de stock bas. Votre taux de prise alimente le pilier d’observance du tableau de bord et le contexte de l’IA."
+ },
+ "labs": {
+ "title": "Valeurs de laboratoire",
+ "body": "Laboratoire recense les biomarqueurs avec plages de référence — hémogramme, cholestérol, HbA1c. Atout : import et export FHIR. Les valeurs hors plage réapparaissent comme preuves dans les analyses et le rapport médical."
+ },
+ "illness": {
+ "title": "Journal des maladies",
+ "body": "Le journal des maladies consigne les épisodes aigus et les diagnostics chroniques avec leurs poussées. Caché : corréler un épisode à vos mesures et le Mode repos qui suspend les rappels. Le Coach comprend ainsi pourquoi les valeurs dérivent un temps."
+ },
+ "vorsorge": {
+ "title": "Prévention",
+ "body": "Prévention vous rappelle examens et vaccins selon leur propre intervalle. Astuce : « Fait » peut créer directement une mesure ou un résultat de laboratoire. Les échéances apparaissent en rappels et dans le rapport médical."
+ },
+ "cycle": {
+ "title": "Cycle",
+ "body": "Le suivi du cycle consigne phases et symptômes. Caché : comparer les symptômes à l’humeur et au sommeil. Le cycle donne du contexte aux variations qui paraîtraient autrement inexpliquées."
+ },
+ "mood": {
+ "title": "Humeur",
+ "body": "Humeur saisit votre ressenti et des tags en deux secondes. Atout : vos propres tags. Les tendances d’humeur se corrèlent au sommeil, aux médicaments et au cycle — le Coach repère les schémas."
},
"insights": {
- "title": "Insights automatiques",
- "body": "Insights transforme tes valeurs en un résumé santé clair, avec un classement de la tension, du poids et de l’humeur. Clé personnelle ou par défaut — c’est toi qui choisis."
+ "title": "Analyses",
+ "body": "Analyses, c’est le cœur : une analyse dynamique et fondée sur des preuves plutôt que des conseils génériques — chaque affirmation pointe vos données réelles. Caché : la disposition est personnalisable. Analyses réunit tout ce que les modules ci-dessus fournissent."
+ },
+ "coach": {
+ "title": "Coach",
+ "body": "Le Coach répond à vos questions sur vos données en dialogue, en utilisant antécédents, médicaments et résultats comme contexte. Vous choisissez le fournisseur IA (votre clé ou local). Il ne recalcule rien — il lit les mêmes chiffres que le tableau de bord."
},
"integrations": {
- "title": "Connecter des appareils",
- "body": "Dans les paramètres, tu connectes Withings (synchronisation automatique du poids, de la tension et du pouls), moodLog ou ton fournisseur d’analyse. Tout reste optionnel et chiffré."
+ "title": "Intégrations",
+ "body": "Les intégrations synchronisent Withings, WHOOP, Polar, Oura et Apple Santé automatiquement. Poids, sommeil et récupération se remplissent sans saisie, et alimentent à leur tour les analyses et le Coach."
+ },
+ "export": {
+ "title": "Rapport médical",
+ "body": "Le rapport médical regroupe mesures, médicaments, valeurs de laboratoire et prévention en PDF ou FHIR pour le rendez-vous. Tous les modules convergent ici — un rapport complet ne vient que de données bien tenues."
},
"achievements": {
- "title": "Collecter séries et badges",
- "body": "La page Succès suit les séries de saisie quotidienne, les paliers de tension et les badges de constance. Un petit coup de pouce pour rester régulier."
+ "title": "Réussites",
+ "body": "Les réussites récompensent la régularité — séries et jalons. Une petite boucle de motivation qui aide à tenir bon."
+ },
+ "wrapUp": {
+ "title": "C’est la fin de la visite",
+ "body": "Vous pouvez revoir chaque module séparément à tout moment (« Afficher cette visite » sur chaque page) ou relancer la visite complète dans Réglages → Avancé."
}
- }
+ },
+ "moduleTrigger": "Afficher cette visite"
},
"shell": {
"back": "Retour",
diff --git a/messages/it.json b/messages/it.json
index 3aef973cb..2f80c5675 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -3647,31 +3647,72 @@
"back": "Indietro",
"skip": "Salta tour",
"done": "Ho capito",
- "restart": "Riavvia tour",
- "restartHint": "Mostra di nuovo il tour della dashboard alla prossima apertura.",
- "restartConfirmation": "Il tour ripartirà alla tua prossima visita alla dashboard.",
+ "restart": "Riavvia la visita ai moduli",
+ "restartHint": "Riproduce la visita guidata ai moduli dall’inizio al tuo prossimo accesso alla dashboard.",
+ "restartConfirmation": "La visita ai moduli ripartirà dall’inizio al tuo prossimo accesso alla dashboard.",
"steps": {
- "tileStrip": {
- "title": "I tuoi valori di oggi",
- "body": "La striscia di tile mostra l’ultimo valore e il trend a 7 giorni di ogni metrica — peso, pressione, pulsazioni, umore. Nascondi ciò che non ti serve in Impostazioni → Dashboard."
+ "dashboardOverview": {
+ "title": "La tua dashboard",
+ "body": "La dashboard riunisce tutti i moduli a colpo d’occhio. Tieni premuta una scheda per riordinarla o nasconderla (Impostazioni → Layout dashboard). Una scheda vale quanto i dati che ha dietro: i moduli successivi li riempiono."
},
"quickAdd": {
- "title": "Registrato in due tap",
- "body": "Con il pulsante Aggiungi registri misurazioni o voci di umore senza lasciare la dashboard. Il modulo ricorda il contesto e i tag più recenti."
+ "title": "Registrato in pochi secondi",
+ "body": "Con « + Aggiungi » registri qualsiasi misurazione in pochi secondi — peso, pressione, polso, glicemia. Forza nascosta: retrodatare i valori dimenticati. Tutto ciò che inserisci qui alimenta tendenze, punteggio salute e analisi IA."
+ },
+ "measurements": {
+ "title": "Misurazioni e intervalli obiettivo",
+ "body": "Misurazioni mostra lo storico e l’intervallo obiettivo per ogni metrica. Chicca nascosta: definire un intervallo per valore così gli scostamenti risaltano a colori. Queste serie sono la base delle correlazioni del Coach e del referto medico."
+ },
+ "medications": {
+ "title": "Farmaci e assunzioni",
+ "body": "Farmaci gestisce orari, assunzioni, scorte e titolazione. Nascosto: piani ricorrenti (RRULE/scorrevoli) e avvisi di scorte basse. La tua aderenza alimenta il pilastro di compliance nella dashboard e il contesto dell’IA."
+ },
+ "labs": {
+ "title": "Valori di laboratorio",
+ "body": "Laboratorio raccoglie i biomarcatori con intervalli di riferimento — emocromo, colesterolo, HbA1c. Forza: importazione ed esportazione FHIR. I valori fuori intervallo riemergono come prove nelle analisi e nel referto medico."
+ },
+ "illness": {
+ "title": "Diario delle malattie",
+ "body": "Il diario delle malattie registra episodi acuti e diagnosi croniche con le loro riacutizzazioni. Nascosto: correlare un episodio alle tue misurazioni e la Modalità riposo che mette in pausa i promemoria. Così il Coach capisce perché i valori variano per un periodo."
+ },
+ "vorsorge": {
+ "title": "Prevenzione",
+ "body": "Prevenzione ti ricorda controlli e vaccinazioni con un proprio intervallo. Suggerimento: «Fatto» può creare direttamente una misurazione o un risultato di laboratorio. Le scadenze compaiono come promemoria e nel referto medico."
+ },
+ "cycle": {
+ "title": "Ciclo",
+ "body": "Il monitoraggio del ciclo registra fasi e sintomi. Nascosto: confrontare i sintomi con umore e sonno. Il ciclo dà contesto alle variazioni che altrimenti sembrerebbero inspiegabili."
+ },
+ "mood": {
+ "title": "Umore",
+ "body": "Umore cattura come ti senti e dei tag in due secondi. Forza: i tuoi tag personalizzati. Gli andamenti dell’umore si correlano con sonno, farmaci e ciclo — il Coach coglie gli schemi."
},
"insights": {
- "title": "Insights automatici",
- "body": "Insights trasforma i tuoi valori in un riepilogo chiaro della salute, con inquadramento di pressione, peso e umore. Chiave personale o predefinita — scegli tu."
+ "title": "Analisi",
+ "body": "Analisi è il cuore: analisi dinamica e basata su prove invece di consigli generici — ogni affermazione punta ai tuoi dati reali. Nascosto: il layout è personalizzabile. Analisi raccoglie tutto ciò che forniscono i moduli sopra."
+ },
+ "coach": {
+ "title": "Coach",
+ "body": "Il Coach risponde alle domande sui tuoi dati in dialogo, usando anamnesi, farmaci e valori di laboratorio come contesto. Scegli tu il fornitore IA (chiave tua o locale). Non ricalcola nulla: legge gli stessi numeri della dashboard."
},
"integrations": {
- "title": "Collegare dispositivi",
- "body": "Dalle impostazioni colleghi Withings (sincronizzazione automatica di peso, pressione e pulsazioni), moodLog o il tuo provider di analisi. Tutto resta opzionale e cifrato."
+ "title": "Integrazioni",
+ "body": "Le integrazioni sincronizzano Withings, WHOOP, Polar, Oura e Apple Salute automaticamente. Peso, sonno e recupero si riempiono senza digitare e a loro volta alimentano analisi e Coach."
+ },
+ "export": {
+ "title": "Referto medico",
+ "body": "Il referto medico raccoglie misurazioni, farmaci, valori di laboratorio e prevenzione in PDF o FHIR per la visita. Qui convergono tutti i moduli: un referto completo nasce solo da dati ben curati."
},
"achievements": {
- "title": "Accumula serie e badge",
- "body": "La pagina Traguardi tiene traccia delle serie di registrazioni giornaliere, dei traguardi di pressione e dei badge di costanza. Un piccolo incentivo per non mollare."
+ "title": "Traguardi",
+ "body": "I traguardi premiano la costanza — serie e tappe. Un piccolo ciclo di motivazione che rende più facile non mollare."
+ },
+ "wrapUp": {
+ "title": "Questa è la visita guidata",
+ "body": "Puoi rivedere ogni modulo singolarmente in qualsiasi momento («Mostra questa visita» su ogni pagina) o riavviare l’intera visita da Impostazioni → Avanzate."
}
- }
+ },
+ "moduleTrigger": "Mostra questa visita"
},
"shell": {
"back": "Indietro",
diff --git a/messages/pl.json b/messages/pl.json
index d91c6f7bd..2c637227b 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -3647,31 +3647,72 @@
"back": "Wstecz",
"skip": "Pomiń tour",
"done": "Rozumiem",
- "restart": "Uruchom tour ponownie",
- "restartHint": "Pokaż tour panelu przy następnym wejściu.",
- "restartConfirmation": "Tour ruszy ponownie przy twojej następnej wizycie w panelu.",
+ "restart": "Uruchom przewodnik modułów ponownie",
+ "restartHint": "Odtwarza przewodnik po modułach od początku przy następnej wizycie na pulpicie.",
+ "restartConfirmation": "Przewodnik po modułach uruchomi się od początku przy następnej wizycie na pulpicie.",
"steps": {
- "tileStrip": {
- "title": "Twoje wartości dzienne",
- "body": "Pasek kafelków pokazuje ostatnią wartość i 7-dniowy trend dla każdej metryki — waga, ciśnienie, tętno, nastrój. Niepotrzebne wyłączysz w Ustawienia → Panel."
+ "dashboardOverview": {
+ "title": "Twój pulpit",
+ "body": "Pulpit zbiera wszystkie moduły na jednym ekranie. Przytrzymaj kafelek, aby go przestawić lub ukryć (Ustawienia → Układ pulpitu). Kafelek jest wart tyle, ile dane za nim — kolejne moduły je wypełniają."
},
"quickAdd": {
- "title": "Zarejestrowane w dwóch stuknięciach",
- "body": "Przyciskiem Dodaj zapisujesz pomiary lub nastroje bez opuszczania panelu. Formularz zapamiętuje ostatni kontekst i tagi."
+ "title": "Zapisane w kilka sekund",
+ "body": "Przez „+ Dodaj” zapiszesz każdy pomiar w sekundy — wagę, ciśnienie, tętno, glukozę. Ukryta moc: datowanie wstecz zapomnianych wartości. Wszystko, co tu wpiszesz, zasila trendy, wynik zdrowia i analizy AI."
+ },
+ "measurements": {
+ "title": "Pomiary i zakresy docelowe",
+ "body": "Pomiary pokazują historię i zakres docelowy każdej metryki. Ukryta perełka: ustaw zakres na wartość, aby odchylenia rzucały się w oczy kolorem. Te serie są podstawą korelacji Coacha i raportu dla lekarza."
+ },
+ "medications": {
+ "title": "Leki i przyjęcia",
+ "body": "Leki zarządzają harmonogramami, przyjęciami, zapasem i titracją. Ukryte: plany cykliczne (RRULE/przesuwne) i ostrzeżenia o niskim zapasie. Twój wskaźnik przyjęć zasila filar stosowania na pulpicie oraz kontekst AI."
+ },
+ "labs": {
+ "title": "Wyniki laboratoryjne",
+ "body": "Laboratorium gromadzi biomarkery z zakresami referencyjnymi — morfologia, cholesterol, HbA1c. Atut: import i eksport FHIR. Wartości poza zakresem wracają jako dowody w analizach i w raporcie dla lekarza."
+ },
+ "illness": {
+ "title": "Dziennik chorób",
+ "body": "Dziennik chorób zapisuje ostre epizody i przewlekłe diagnozy wraz z zaostrzeniami. Ukryte: korelacja epizodu z pomiarami oraz Tryb odpoczynku wstrzymujący przypomnienia. Dzięki temu Coach rozumie, czemu wartości przez chwilę odbiegają."
+ },
+ "vorsorge": {
+ "title": "Profilaktyka",
+ "body": "Profilaktyka przypomina o badaniach i szczepieniach z własnym interwałem. Wskazówka: „Wykonane” może od razu utworzyć pomiar lub wynik laboratoryjny. Terminy pojawiają się jako przypomnienia i w raporcie dla lekarza."
+ },
+ "cycle": {
+ "title": "Cykl",
+ "body": "Śledzenie cyklu zapisuje fazy i objawy. Ukryte: zestawienie objawów z nastrojem i snem. Cykl daje kontekst wahaniom, które inaczej wyglądałyby na niewyjaśnione."
+ },
+ "mood": {
+ "title": "Nastrój",
+ "body": "Nastrój zapisuje samopoczucie i tagi w dwie sekundy. Atut: własne tagi. Trendy nastroju korelują ze snem, lekami i cyklem — Coach dostrzega wzorce."
},
"insights": {
- "title": "Automatyczne insights",
- "body": "Insights zamienia twoje wartości w przejrzysty przegląd zdrowia, z oceną ciśnienia, wagi i nastroju. Własny klucz lub domyślny — ty decydujesz."
+ "title": "Analizy",
+ "body": "Analizy to serce aplikacji: dynamiczna analiza oparta na dowodach zamiast ogólnych porad — każde stwierdzenie wskazuje na Twoje realne dane. Ukryte: układ można dostosować. Analizy łączą wszystko, co dostarczają moduły powyżej."
+ },
+ "coach": {
+ "title": "Coach",
+ "body": "Coach odpowiada na pytania o Twoje dane w dialogu, korzystając z wywiadu, leków i wyników jako kontekstu. Dostawcę AI wybierasz sam (własny klucz lub lokalnie). Niczego nie przelicza — czyta te same liczby co pulpit."
},
"integrations": {
- "title": "Połącz urządzenia",
- "body": "W ustawieniach łączysz Withings (auto-sync wagi, ciśnienia i tętna), moodLog lub dostawcę analizy. Wszystko zostaje opcjonalne i zaszyfrowane."
+ "title": "Integracje",
+ "body": "Integracje synchronizują Withings, WHOOP, Polar, Oura i Apple Health automatycznie. Waga, sen i regeneracja uzupełniają się bez wpisywania, a z kolei zasilają analizy i Coacha."
+ },
+ "export": {
+ "title": "Raport dla lekarza",
+ "body": "Raport dla lekarza łączy pomiary, leki, wyniki laboratoryjne i profilaktykę w PDF lub FHIR na wizytę. Tu zbiegają się wszystkie moduły — pełny raport powstaje tylko z zadbanych danych."
},
"achievements": {
- "title": "Zbieraj serie i odznaki",
- "body": "Strona Osiągnięć śledzi codzienne serie zapisów, kamienie milowe ciśnienia i odznaki konsekwencji. Drobna motywacja do trzymania się planu."
+ "title": "Osiągnięcia",
+ "body": "Osiągnięcia nagradzają konsekwencję — passy i kamienie milowe. Mała pętla motywacji, dzięki której łatwiej wytrwać."
+ },
+ "wrapUp": {
+ "title": "To koniec przewodnika",
+ "body": "Każdy moduł możesz obejrzeć osobno w dowolnej chwili („Pokaż ten przewodnik” na każdej stronie) lub uruchomić cały przewodnik ponownie w Ustawienia → Zaawansowane."
}
- }
+ },
+ "moduleTrigger": "Pokaż ten przewodnik"
},
"shell": {
"back": "Wstecz",
diff --git a/src/app/page.tsx b/src/app/page.tsx
index c7a111143..4d03b3ff8 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -52,7 +52,6 @@ import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton";
import { TrendHint } from "@/components/charts/trend-hint";
import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta";
import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist";
-import { TourLauncher } from "@/components/onboarding/tour-launcher";
import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card";
import { RecentWorkoutsTile } from "@/components/dashboard/recent-workouts-tile";
import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsorge-dashboard-card";
@@ -753,15 +752,10 @@ export default function DashboardPage() {
/>
))}
- {/* v1.4.15 Phase B5 — spotlight tour for first-time users.
- * Self-gates on `user.onboardingTourCompleted` (DB flag) plus
- * a session-storage dismiss guard. We pass `ready=true` only
- * after analytics has resolved — the tour anchors to the tile
- * strip and we don't want the cutout snapping to a 0×0
- * placeholder before tiles render. The launcher mounts a no-op
- * `null` when the user has already seen the tour, so this
- * line is free for established users. */}
-
+ {/* v1.18.6 — the spotlight tour launcher moved to the app-shell
+ * (`AuthShell`) so its overlay survives the cross-page
+ * `router.push`es the module tour makes. The dashboard no longer
+ * mounts it. */}
{/* Quick Entry Sheets — bottom-sheet on `
+ {/* v1.18.6 — the module-tour launcher lives at the shell level so its
+ overlay survives the cross-page `router.push`es the tour makes. It
+ self-gates: it only auto-opens on the dashboard for a user who has
+ not finished/dismissed the tour, and otherwise renders null. Stays
+ out of demo mode (the tour PATCHes `/api/onboarding/tour`, which the
+ proxy blocks for demo visitors). */}
+ {!demoMode && }
{/* v1.16.13 — the Coach FAB stays hidden in demo mode. Its send hits
`/api/insights/chat`, which is not in the proxy's
`DEMO_MUTATION_ALLOWLIST`, so a demo visitor who tapped it and sent
diff --git a/src/components/onboarding/DoneScreen.tsx b/src/components/onboarding/DoneScreen.tsx
index 9b924a123..46d8ca25b 100644
--- a/src/components/onboarding/DoneScreen.tsx
+++ b/src/components/onboarding/DoneScreen.tsx
@@ -1,9 +1,12 @@
"use client";
import Link from "next/link";
+import { useEffect } from "react";
import { CheckCircle2, FileUp, PlusCircle, Plug } from "lucide-react";
import { Button } from "@/components/ui/button";
+import { useAuth } from "@/hooks/use-auth";
+import { setTourReferrer } from "@/components/onboarding/tour-launcher";
import { useTranslations } from "@/lib/i18n/context";
/**
@@ -18,10 +21,21 @@ import { useTranslations } from "@/lib/i18n/context";
* through `next/link` so the regular client-side navigation kicks in
* (the proxy redirect was cleared with the same step-API write that
* landed the user here).
+ *
+ * v1.18.6 — on mount we write the per-user wizard-return marker so the
+ * shell-level `` auto-opens the module tour immediately
+ * on the next dashboard arrival (sequenced after the post-wizard
+ * grace), instead of waiting out the 24 h fallback. Whichever exit the
+ * user picks from this screen, the next `/` mount finds the marker.
*/
export function DoneScreen() {
const { t } = useTranslations();
+ const { user } = useAuth();
+
+ useEffect(() => {
+ if (user?.id) setTourReferrer(user.id);
+ }, [user?.id]);
return (
({
+ useRouter: () => ({ push: vi.fn() }),
+ usePathname: () => "/",
+}));
import { I18nProvider } from "@/lib/i18n/context";
import { OnboardingTour } from "../tour";
-function renderTour(includeAchievements = true) {
+function renderTour(modules?: Record) {
return renderToStaticMarkup(
-
+ ,
);
}
@@ -31,25 +35,23 @@ describe("", () => {
expect(html).toContain('aria-labelledby="onboarding-tour-title"');
});
- it("starts on the tile-strip step (1 of 5 by default)", () => {
+ it("starts on the dashboard-overview step (1 of 15 by default)", () => {
const html = renderTour();
- expect(html).toContain("Step 1 of 5");
- expect(html).toContain("Your daily numbers");
- // Body of the first step should mention the tile strip context.
- expect(html).toContain("tile strip");
+ expect(html).toContain("Step 1 of 15");
});
- it("drops the achievements stop when the flag is off (1 of 4)", () => {
- const html = renderTour(false);
- expect(html).toContain("Step 1 of 4");
+ it("keeps the counter honest when modules are disabled", () => {
+ // cycle + mood + achievements off ⇒ 12 stops.
+ const html = renderTour({
+ cycle: false,
+ mood: false,
+ achievements: false,
+ });
+ expect(html).toContain("Step 1 of 12");
});
it("exposes a skip control with an accessible label", () => {
const html = renderTour();
- // After v1.4.33 F2 the dim panels are purely visual
- // (pointer-events: none) so skip lives entirely on the explicit
- // tooltip-footer Skip button. The visible label still has to be
- // there — both for sighted users and for screen readers.
expect(html).toContain("Skip tour");
});
@@ -77,8 +79,7 @@ describe("", () => {
,
);
- expect(html).toContain("Schritt 1 von 5");
- expect(html).toContain("Deine Tageswerte");
+ expect(html).toContain("Schritt 1 von 15");
expect(html).toContain("Tour überspringen");
});
});
diff --git a/src/components/onboarding/tour-launcher.tsx b/src/components/onboarding/tour-launcher.tsx
index 3095542df..4254e0770 100644
--- a/src/components/onboarding/tour-launcher.tsx
+++ b/src/components/onboarding/tour-launcher.tsx
@@ -47,11 +47,13 @@
* manual replay always works.
*/
+import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { apiPost } from "@/lib/api/api-fetch";
+import type { TourStopId } from "@/lib/onboarding/tour-state";
import { OnboardingTour } from "./tour";
const POST_WIZARD_GRACE_MS = 1500;
@@ -90,6 +92,16 @@ export function shouldAutoLaunchTour(args: {
onboardingTourCompleted: boolean;
onboardingCompletedAt: string | null;
nowMs: number;
+ /**
+ * v1.18.6 — true when the user has just returned from the wizard's
+ * DoneScreen this session (the `tourReferrerKey` marker). A fresh
+ * user finishing anamnesis should get the module tour IMMEDIATELY —
+ * the post-wizard grace below still sequences it after the toast.
+ * The old 24 h delay over-corrected and removed first-run capability
+ * education entirely; it now only applies to a user who SKIPPED the
+ * wizard (no referrer marker, completion ≥ 24 h ago).
+ */
+ justFromWizard?: boolean;
}): boolean {
if (args.onboardingTourCompleted) return false;
if (args.onboardingCompletedAt == null) return false;
@@ -98,6 +110,9 @@ export function shouldAutoLaunchTour(args: {
// unparseable case as "no completion yet" so a malformed payload
// never auto-launches the tour into an unsuspecting brand-new user.
if (Number.isNaN(completedMs)) return false;
+ // Fresh-from-anamnesis: launch now (sequenced after the post-wizard
+ // grace by the launcher), no 24 h wait.
+ if (args.justFromWizard) return true;
return args.nowMs - completedMs >= TOUR_AUTOLAUNCH_DELAY_MS;
}
@@ -189,6 +204,22 @@ function readAndClearForceLaunch(userId: string): boolean {
* does not consult sessionStorage at all (the gating effect waits
* for `inputsReady` first).
*/
+/**
+ * v1.18.6 — write the wizard-return marker. Called from the wizard's
+ * `DoneScreen` on mount so the launcher knows the user JUST finished
+ * anamnesis and should get the module tour immediately (sequenced after
+ * the post-wizard grace) rather than after the 24 h fallback delay.
+ * Per-user keyed so a shared browser keeps each identity's state apart.
+ */
+export function setTourReferrer(userId: string): void {
+ if (typeof window === "undefined") return;
+ try {
+ window.sessionStorage.setItem(tourReferrerKey(userId), "1");
+ } catch {
+ /* ignore — sandboxed iframes etc. */
+ }
+}
+
function readAndClearJustFromWizard(userId: string): boolean {
if (typeof window === "undefined") return false;
try {
@@ -233,24 +264,30 @@ function clearSessionDismissed(userId: string) {
interface TourLauncherProps {
/**
- * The dashboard passes a sentinel — true once analytics has
- * resolved at least once. Without this gate the spotlight would
- * try to anchor to tiles that haven't rendered yet, and Recharts
- * widgets defer mounting too. This is a simple, race-free signal
- * the dashboard already computes.
+ * v1.18.6 — optional readiness sentinel. The dashboard used to pass
+ * `true` once analytics resolved so the spotlight didn't anchor to a
+ * 0×0 tile. The overlay now polls for its anchor (bounded retry), so
+ * the gate is no longer load-bearing; it defaults to `true` for the
+ * shell-level mount. The launcher still only auto-opens on the
+ * dashboard route, where the first stop's anchor lives.
*/
- ready: boolean;
+ ready?: boolean;
}
-export function TourLauncher({ ready }: TourLauncherProps) {
+export function TourLauncher({ ready = true }: TourLauncherProps) {
const { user, isAuthenticated, isLoading } = useAuth();
const queryClient = useQueryClient();
+ const pathname = usePathname();
// `null` = decision not yet made; `true` = render the tour;
// `false` = don't render. We only flip null→true ONCE per mount;
// closing the tour transitions true→false and we never re-open
// unless the restart event fires.
const [showTour, setShowTour] = useState(null);
+ // v1.18.6 — when the tour opens for a single-module re-entry
+ // ("Diese Tour zeigen" on a module page) this carries the stop id so
+ // the overlay renders just that card. `null` = the full tour.
+ const [singleStop, setSingleStop] = useState(null);
// Track post-wizard deferral as a boolean so the render path stays
// pure (no Date.now() comparisons). The effect below reads / clears it.
const [deferredFromWizard, setDeferredFromWizard] = useState(false);
@@ -275,7 +312,14 @@ export function TourLauncher({ ready }: TourLauncherProps) {
// tour on their next page-load — acceptable for a one-shot welcome).
const [mountedAtMs] = useState(() => Date.now());
- const inputsReady = !isLoading && isAuthenticated && !!user && ready;
+ // v1.18.6 — the launcher now mounts at the app-shell level so the
+ // overlay survives the cross-page `router.push`es the tour makes. It
+ // must only AUTO-open on the dashboard, where the first stop's anchor
+ // lives. A manual re-entry / restart can fire from any route (handled
+ // by the event listeners below, which don't consult this gate).
+ const onDashboard = pathname === "/";
+ const inputsReady =
+ !isLoading && isAuthenticated && !!user && ready && onDashboard;
// The set-state-in-render pattern (mirrors `account-section.tsx`'s
// `seededUserId` — React-recommended for "compute initial state
// from props once they arrive"). Pure: branches on prop values
@@ -289,6 +333,10 @@ export function TourLauncher({ ready }: TourLauncherProps) {
decidedFor.flag !== user.onboardingTourCompleted)
) {
setDecidedFor({ userId: user.id, flag: user.onboardingTourCompleted });
+ // v1.18.6 — consume the wizard-return marker once so the
+ // fresh-from-anamnesis path launches immediately (sequenced after
+ // the post-wizard grace) instead of waiting 24 h.
+ const justFromWizard = readAndClearJustFromWizard(user.id);
// sessionStorage reads happen via per-user keyed helpers, so a
// browser shared by two HealthLog users (admin impersonation,
// family laptop, etc.) keeps each user's tour state independent.
@@ -309,17 +357,21 @@ export function TourLauncher({ ready }: TourLauncherProps) {
// user expectation ("I clicked replay, now show me the tour").
if (readAndClearForceLaunch(user.id)) {
setShowTour(true);
- } else if (!shouldAutoLaunchTour({
- onboardingTourCompleted: user.onboardingTourCompleted,
- onboardingCompletedAt: user.onboardingCompletedAt,
- nowMs: mountedAtMs,
- })) {
+ } else if (
+ !shouldAutoLaunchTour({
+ onboardingTourCompleted: user.onboardingTourCompleted,
+ onboardingCompletedAt: user.onboardingCompletedAt,
+ nowMs: mountedAtMs,
+ justFromWizard,
+ })
+ ) {
setShowTour(false);
} else if (readSessionDismissed(user.id)) {
setShowTour(false);
- } else if (readAndClearJustFromWizard(user.id)) {
- // Mark "deferred"; the effect below schedules a fixed-duration
- // timer + flips `showTour` from inside the timeout callback
+ } else if (justFromWizard) {
+ // Fresh from anamnesis — defer past the post-wizard grace so the
+ // green-tick toast doesn't double-overlay, then open. The effect
+ // below schedules the timer and flips `showTour` in its callback
// (which the set-state-in-effect rule allows).
setShowTour(false);
setDeferredFromWizard(true);
@@ -344,13 +396,15 @@ export function TourLauncher({ ready }: TourLauncherProps) {
return () => window.clearTimeout(id);
}, [deferredFromWizard, user?.id]);
- // Listen for explicit restart events from Settings → Account.
+ // Listen for explicit restart events from Settings → Advanced. A
+ // restart opens the FULL tour from the top (singleStop cleared).
// setState inside a window-event listener is fine under the
// set-state-in-effect rule.
const userIdForRestart = user?.id;
useEffect(() => {
function onRestart() {
if (userIdForRestart) clearSessionDismissed(userIdForRestart);
+ setSingleStop(null);
setShowTour(true);
}
window.addEventListener("healthlog:tour-restart", onRestart);
@@ -359,17 +413,48 @@ export function TourLauncher({ ready }: TourLauncherProps) {
};
}, [userIdForRestart]);
+ // v1.18.6 — per-module "Diese Tour zeigen" re-entry. A module page
+ // dispatches `healthlog:module-tour` with `{ stopId }`; the launcher
+ // opens the overlay narrowed to that single card on the current page
+ // (no cross-page navigation, no completion flip).
+ useEffect(() => {
+ function onModuleTour(e: Event) {
+ const detail = (e as CustomEvent<{ stopId?: string }>).detail;
+ if (!detail?.stopId) return;
+ setSingleStop(detail.stopId as TourStopId);
+ setShowTour(true);
+ }
+ window.addEventListener("healthlog:module-tour", onModuleTour);
+ return () => {
+ window.removeEventListener("healthlog:module-tour", onModuleTour);
+ };
+ }, []);
+
if (showTour !== true) return null;
- // v1.18.0 B5 — drop the achievements tour stop when the account has the
- // achievements module turned off. Reads the per-user module map the same
- // way every other client gate does (`modules?. !== false`,
- // default-on).
- const includeAchievements = tourIncludesAchievements(user?.modules);
+ // The resolved module map gates which stops appear (default-on) and
+ // keeps the "Schritt n/total" counter honest.
+ const modules = user?.modules;
+ // v1.18.6 — resume the full tour from the persisted checkpoint. The
+ // single-stop re-entry ignores the resume point (it opens its card).
+ const resumeFromStopId = singleStop
+ ? null
+ : (user?.onboardingTourProgress?.lastStopId ?? null);
return (
{
+ // Fire-and-forget checkpoint so a mid-tour reload resumes at the
+ // right module. Skipped implicitly by the overlay for the
+ // single-stop re-entry. A failed write is non-fatal — the resume
+ // point is a convenience, not a correctness invariant.
+ void apiPost("/api/onboarding/tour", {
+ progress: { ...progress, updatedAt: new Date().toISOString() },
+ }).catch(() => {});
+ }}
onClose={async (outcome) => {
// Optimistic: hide the overlay immediately, persist in the
// background. The flag is idempotent so re-fires are safe;
@@ -378,7 +463,13 @@ export function TourLauncher({ ready }: TourLauncherProps) {
// for the rest of the session, and the next refetch of
// `/api/auth/me` (which the user gets on any nav) will
// surface any divergence.
+ const wasSingleStop = singleStop !== null;
setShowTour(false);
+ setSingleStop(null);
+ // A single-stop re-entry must NOT flip the global completion
+ // flag or write the session-dismiss guard — it's one card, not
+ // "the tour". Just close.
+ if (wasSingleStop) return;
if (user?.id) writeSessionDismissed(user.id);
try {
await apiPost("/api/onboarding/tour", { completed: true, outcome });
diff --git a/src/components/onboarding/tour.tsx b/src/components/onboarding/tour.tsx
index ae350d47e..852828568 100644
--- a/src/components/onboarding/tour.tsx
+++ b/src/components/onboarding/tour.tsx
@@ -36,6 +36,7 @@
* `skipped` to the API + audit log).
*/
+import { usePathname, useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
@@ -43,26 +44,46 @@ import { useTranslations } from "@/lib/i18n/context";
import {
buildTourStops,
currentStop,
+ deriveProgress,
initTourState,
isTourFinished,
nextStep,
prevStep,
skipTour,
stepCounter,
+ type TourModuleMap,
+ type TourState,
type TourStop,
+ type TourStopId,
} from "@/lib/onboarding/tour-state";
interface TourProps {
/**
- * Whether the achievements page exists and should be a tour stop.
- * The launcher passes `false` if B4 hasn't shipped to skip the
- * stop without renumbering.
+ * Resolved module map (`GET /api/auth/me`'s `modules`). Stops whose
+ * `requiresModule` resolves to `false` are dropped; the counter total
+ * tracks the resolved list so "Schritt n/total" stays honest.
*/
- includeAchievements?: boolean;
+ modules?: TourModuleMap;
/**
- * Called when the tour finishes (Done OR Skip OR Esc OR Backdrop
- * click). Receives the terminal outcome so the launcher can audit
- * the difference.
+ * v1.18.6 — open at this stop id (the persisted resume point). When
+ * the id is absent from the resolved list the tour starts at the top.
+ */
+ resumeFromStopId?: string | null;
+ /**
+ * v1.18.6 — narrow the tour to a single module card (the per-page
+ * "Diese Tour zeigen" re-entry). When set, the overlay shows just
+ * that stop with a Done button and no cross-page navigation.
+ */
+ filterToStop?: TourStopId;
+ /**
+ * v1.18.6 — fire-and-forget progress checkpoint on every step change
+ * + terminal. The launcher PATCHes it so a reload resumes correctly.
+ * Not called for the single-stop re-entry (`filterToStop`).
+ */
+ onProgress?: (progress: ReturnType) => void;
+ /**
+ * Called when the tour finishes (Done OR Skip OR Esc). Receives the
+ * terminal outcome so the launcher can audit the difference.
*/
onClose: (outcome: "completed" | "skipped") => void;
}
@@ -216,56 +237,125 @@ const COLLISION_PAD = 8;
const SHEET_BREAKPOINT = 480;
export function OnboardingTour({
- includeAchievements = true,
+ modules,
+ resumeFromStopId,
+ filterToStop,
+ onProgress,
onClose,
}: TourProps) {
const { t } = useTranslations();
+ const router = useRouter();
+ const pathname = usePathname();
const stops = useMemo(
- () => buildTourStops({ includeAchievements }),
- [includeAchievements],
+ () => buildTourStops({ modules, filterToStop }),
+ [modules, filterToStop],
+ );
+ // Single-stop re-entry never resumes from a checkpoint — it opens on
+ // the one card the page asked for.
+ const [state, setState] = useState(() =>
+ initTourState(stops, filterToStop ? null : resumeFromStopId),
);
- const [state, setState] = useState(() => initTourState(stops));
const [rect, setRect] = useState(null);
const stop = currentStop(state);
- // Re-measure on step change, viewport resize, and scroll. We intentionally
- // *don't* MutationObserver the whole document — the targets are stable in
- // the layout and re-measuring on rAF after scroll/resize is plenty.
+ // v1.18.6 — cross-page step change. A stop on another route triggers a
+ // `router.push` first; the overlay survives the navigation because it is
+ // mounted at the app-shell level (not inside the dashboard). After the
+ // route settles we must wait for the new page's anchor to MOUNT before
+ // measuring — anchoring against a not-yet-rendered (0×0) target would snap
+ // the spotlight to centre-screen. We poll on rAF with a bounded retry, then
+ // give up to the centred fallback so the tour never blocks indefinitely.
+ //
+ // We intentionally *don't* MutationObserver the whole document — the
+ // targets are stable once mounted; bounded rAF polling on step change plus
+ // re-measuring on scroll/resize is plenty.
useEffect(() => {
if (!stop) return;
+ if (typeof document === "undefined") return;
+
let raf = 0;
+ let cancelled = false;
+ const reduceMotion =
+ typeof window !== "undefined" &&
+ window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
+
+ // Navigate to the stop's route if we're not already there. Same-route
+ // stops (the two dashboard stops while on `/`, or the wrap-up which has
+ // no route) skip the push. `filterToStop` re-entry never navigates — it
+ // runs on the page the user is already viewing.
+ if (!filterToStop && stop.route && stop.route !== pathname) {
+ router.push(stop.route);
+ }
+
+ const measureNow = () => {
+ if (cancelled) return;
+ setRect(measureTarget(stop.targetId));
+ };
const measure = () => {
cancelAnimationFrame(raf);
- raf = requestAnimationFrame(() => {
- setRect(measureTarget(stop.targetId));
- });
+ raf = requestAnimationFrame(measureNow);
};
- // Bring the target into view BEFORE the first measurement of this
- // step — anchors low on the page (e.g. the Settings nav item) can
- // sit entirely below the fold, and positioning against an
- // off-screen rect put the popover under the viewport bottom where
- // its Next button was unreachable. `block: "center"` keeps room on
- // both sides for the flipped placement; the rAF in `measure()`
- // runs after the instant scroll so we measure post-scroll
- // coordinates. Optional-chained: jsdom and older engines lack it.
- if (stop.targetId && typeof document !== "undefined") {
- document
- .querySelector(
- `[data-tour-id="${CSS.escape(stop.targetId)}"]`,
- )
- ?.scrollIntoView?.({ block: "center", inline: "nearest" });
- }
- measure();
+
+ // Wait for the anchor to mount (after navigation it isn't in the DOM on
+ // the first frame). Bounded poll: ~40 frames (~650 ms) before falling
+ // back to the centred tooltip via `measureTarget` returning null.
+ let attempts = 0;
+ const MAX_ATTEMPTS = 40;
+ const settleAndMeasure = () => {
+ if (cancelled) return;
+ const el = stop.targetId
+ ? document.querySelector(
+ `[data-tour-id="${CSS.escape(stop.targetId)}"]`,
+ )
+ : null;
+ if (el) {
+ // Bring the anchor into view BEFORE measuring — anchors below the
+ // fold positioned the popover under the viewport bottom otherwise.
+ // Reduced-motion users get an instant jump, not a smooth scroll.
+ el.scrollIntoView?.({
+ block: "center",
+ inline: "nearest",
+ behavior: reduceMotion ? "auto" : "smooth",
+ });
+ measure();
+ return;
+ }
+ // Centred / route-less stop (wrap-up): measure immediately.
+ if (!stop.targetId) {
+ measureNow();
+ return;
+ }
+ attempts += 1;
+ if (attempts < MAX_ATTEMPTS) {
+ raf = requestAnimationFrame(settleAndMeasure);
+ } else {
+ // Anchor never mounted — centre the tooltip rather than block.
+ measureNow();
+ }
+ };
+ settleAndMeasure();
+
window.addEventListener("resize", measure);
window.addEventListener("scroll", measure, true);
return () => {
+ cancelled = true;
cancelAnimationFrame(raf);
window.removeEventListener("resize", measure);
window.removeEventListener("scroll", measure, true);
};
- }, [stop]);
+ // `pathname` is intentionally a dep: after the route settles the effect
+ // re-runs and the anchor poll finds the now-mounted target.
+ }, [stop, filterToStop, router, pathname]);
+
+ // v1.18.6 — fire-and-forget progress checkpoint on every step / terminal
+ // transition so a mid-tour reload resumes at the right module. Skipped for
+ // the single-stop re-entry (it isn't "the tour", just one card).
+ useEffect(() => {
+ if (filterToStop || !onProgress) return;
+ onProgress(deriveProgress(state));
+ }, [state, filterToStop, onProgress]);
// Focus management — moves focus to the primary action when the
// step changes so keyboard users can hit Enter to advance. The
diff --git a/src/lib/onboarding/__tests__/tour-state.test.ts b/src/lib/onboarding/__tests__/tour-state.test.ts
index 450d73ce2..b46b1c8d6 100644
--- a/src/lib/onboarding/__tests__/tour-state.test.ts
+++ b/src/lib/onboarding/__tests__/tour-state.test.ts
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import {
buildTourStops,
currentStop,
+ deriveProgress,
initTourState,
isTourFinished,
nextStep,
@@ -11,48 +12,82 @@ import {
stepCounter,
} from "../tour-state";
+const FULL_ORDER = [
+ "dashboardOverview",
+ "quickAdd",
+ "measurements",
+ "medications",
+ "labs",
+ "illness",
+ "vorsorge",
+ "cycle",
+ "mood",
+ "insights",
+ "coach",
+ "integrations",
+ "export",
+ "achievements",
+ "wrapUp",
+];
+
describe("tour-state", () => {
describe("buildTourStops()", () => {
- it("includes the achievements stop by default (5 stops)", () => {
+ it("returns all 15 module stops in order with every module on", () => {
const stops = buildTourStops();
- expect(stops.map((s) => s.id)).toEqual([
- "tile-strip",
- "quick-add",
- "insights",
- "integrations",
- "achievements",
- ]);
+ expect(stops.map((s) => s.id)).toEqual(FULL_ORDER);
});
- it("omits the achievements stop when the flag is off (4 stops)", () => {
- const stops = buildTourStops({ includeAchievements: false });
- expect(stops.map((s) => s.id)).toEqual([
- "tile-strip",
- "quick-add",
- "insights",
- "integrations",
- ]);
+ it("drops a stop whose module resolves to false (default-on otherwise)", () => {
+ const stops = buildTourStops({
+ modules: { cycle: false, mood: false, achievements: false },
+ });
+ const ids = stops.map((s) => s.id);
+ expect(ids).not.toContain("cycle");
+ expect(ids).not.toContain("mood");
+ expect(ids).not.toContain("achievements");
+ // Core + un-disabled modules survive.
+ expect(ids).toContain("dashboardOverview");
+ expect(ids).toContain("labs");
+ expect(ids).toContain("wrapUp");
});
- it("every stop carries a non-empty i18n title and body key", () => {
- const stops = buildTourStops();
- for (const s of stops) {
+ it("keeps a module stop when its key is absent or true (fail-open)", () => {
+ const stops = buildTourStops({ modules: { labs: true } });
+ expect(stops.map((s) => s.id)).toContain("labs");
+ expect(stops.map((s) => s.id)).toContain("mood");
+ });
+
+ it("filterToStop narrows to a single module card", () => {
+ const stops = buildTourStops({ filterToStop: "labs" });
+ expect(stops.map((s) => s.id)).toEqual(["labs"]);
+ });
+
+ it("filterToStop yields nothing when the module is disabled", () => {
+ const stops = buildTourStops({
+ filterToStop: "cycle",
+ modules: { cycle: false },
+ });
+ expect(stops).toEqual([]);
+ });
+
+ it("every stop carries a distinct, namespaced i18n title and body key", () => {
+ for (const s of buildTourStops()) {
expect(s.titleKey).toMatch(/^onboarding\.tour\.steps\./);
expect(s.bodyKey).toMatch(/^onboarding\.tour\.steps\./);
expect(s.titleKey).not.toBe(s.bodyKey);
}
});
- it("every stop declares a stable targetId for the data-tour-id contract", () => {
- const stops = buildTourStops();
- for (const s of stops) {
- // `null` is allowed (centred-screen tooltip without a
- // spotlight cutout) but we currently use real targets for
- // every stop — a regression here would mean the spotlight
- // silently falls back to centre-screen.
- expect(typeof s.targetId === "string" && s.targetId.length > 0).toBe(
- true,
- );
+ it("every cross-page stop declares a route; the wrap-up is centred + routeless", () => {
+ for (const s of buildTourStops()) {
+ if (s.id === "wrapUp") {
+ expect(s.targetId).toBeNull();
+ expect(s.route).toBeUndefined();
+ expect(s.placement).toBe("center");
+ } else {
+ expect(typeof s.route).toBe("string");
+ expect(s.targetId).toBeTruthy();
+ }
}
});
});
@@ -62,76 +97,97 @@ describe("tour-state", () => {
const state = initTourState(buildTourStops());
expect(state.index).toBe(0);
expect(state.outcome).toBeNull();
- expect(isTourFinished(state)).toBe(false);
- expect(currentStop(state)?.id).toBe("tile-strip");
+ expect(currentStop(state)?.id).toBe("dashboardOverview");
+ });
+
+ it("resumes from a persisted stop id", () => {
+ const state = initTourState(buildTourStops(), "labs");
+ expect(currentStop(state)?.id).toBe("labs");
+ });
+
+ it("ignores a resume id absent from the resolved list", () => {
+ const state = initTourState(
+ buildTourStops({ modules: { cycle: false } }),
+ "cycle",
+ );
+ expect(state.index).toBe(0);
});
it("nextStep advances through the list", () => {
let state = initTourState(buildTourStops());
state = nextStep(state);
- expect(currentStop(state)?.id).toBe("quick-add");
- state = nextStep(state);
- expect(currentStop(state)?.id).toBe("insights");
+ expect(currentStop(state)?.id).toBe("quickAdd");
});
- it("nextStep on the last step finishes the tour with outcome=completed", () => {
+ it("nextStep on the last step finishes with outcome=completed", () => {
const stops = buildTourStops();
let state = initTourState(stops);
- // step through every stop
- for (let i = 0; i < stops.length; i++) {
- state = nextStep(state);
- }
+ for (let i = 0; i < stops.length; i++) state = nextStep(state);
expect(state.outcome).toBe("completed");
expect(isTourFinished(state)).toBe(true);
expect(currentStop(state)).toBeNull();
});
- it("prevStep is pinned at index 0 (LeftArrow on first step is a no-op)", () => {
+ it("prevStep is pinned at index 0", () => {
const state = initTourState(buildTourStops());
- const back = prevStep(state);
- expect(back.index).toBe(0);
- expect(back.outcome).toBeNull();
- });
-
- it("prevStep walks backward when not at the start", () => {
- let state = initTourState(buildTourStops());
- state = nextStep(state);
- state = nextStep(state);
- expect(state.index).toBe(2);
- const back = prevStep(state);
- expect(back.index).toBe(1);
+ expect(prevStep(state).index).toBe(0);
});
it("skipTour from any step finishes with outcome=skipped", () => {
let state = initTourState(buildTourStops());
- state = nextStep(state); // on step 2 of 5
+ state = nextStep(state);
const skipped = skipTour(state);
expect(skipped.outcome).toBe("skipped");
- expect(isTourFinished(skipped)).toBe(true);
expect(currentStop(skipped)).toBeNull();
});
-
- it("nextStep / prevStep / skipTour are no-ops once finished", () => {
- let state = initTourState(buildTourStops());
- state = skipTour(state);
- const after = nextStep(prevStep(skipTour(state)));
- expect(after).toEqual(state);
- });
});
describe("stepCounter", () => {
- it("reports 1-based current and the total step count", () => {
+ it("reports 1-based current and the resolved total", () => {
const stops = buildTourStops();
let state = initTourState(stops);
expect(stepCounter(state)).toEqual({ current: 1, total: stops.length });
state = nextStep(state);
- expect(stepCounter(state)).toEqual({ current: 2, total: stops.length });
+ expect(stepCounter(state).current).toBe(2);
});
- it("clamps `current` to total when the tour is finished", () => {
- let state = initTourState(buildTourStops({ includeAchievements: false }));
+ it("total tracks the gated list so the counter stays honest", () => {
+ const stops = buildTourStops({ modules: { cycle: false, mood: false } });
+ const state = initTourState(stops);
+ expect(stepCounter(state).total).toBe(stops.length);
+ expect(stops.length).toBe(13);
+ });
+ });
+
+ describe("deriveProgress", () => {
+ it("reports the current stop as the resume point while running", () => {
+ let state = initTourState(buildTourStops());
+ state = nextStep(state);
+ state = nextStep(state);
+ const p = deriveProgress(state);
+ expect(p.lastStopId).toBe("measurements");
+ expect(p.status).toBe("in_progress");
+ expect(p.completedStopIds).toEqual([
+ "dashboardOverview",
+ "quickAdd",
+ "measurements",
+ ]);
+ });
+
+ it("marks completed with every stop reached and a null resume point", () => {
+ const stops = buildTourStops();
+ let state = initTourState(stops);
+ for (let i = 0; i < stops.length; i++) state = nextStep(state);
+ const p = deriveProgress(state);
+ expect(p.status).toBe("completed");
+ expect(p.lastStopId).toBeNull();
+ expect(p.completedStopIds).toHaveLength(stops.length);
+ });
+
+ it("marks skipped", () => {
+ let state = initTourState(buildTourStops());
state = skipTour(state);
- expect(stepCounter(state)).toEqual({ current: 4, total: 4 });
+ expect(deriveProgress(state).status).toBe("skipped");
});
});
});
diff --git a/src/lib/onboarding/tour-state.ts b/src/lib/onboarding/tour-state.ts
index a8776cfc5..adff4cd9f 100644
--- a/src/lib/onboarding/tour-state.ts
+++ b/src/lib/onboarding/tour-state.ts
@@ -1,114 +1,245 @@
/**
* v1.4.15 Phase B5 — onboarding tour state model.
+ * v1.18.6 — rebuilt as a 15-stop module-by-module guided tour.
*
- * Pure data + state-machine helpers for the spotlight tour overlaid on
- * the dashboard for first-time users. Kept DOM-free so vitest (Node
- * environment, no jsdom) can exercise the navigation logic directly,
- * matching the project test-style elsewhere.
+ * Pure data + state-machine helpers for the spotlight tour. Kept
+ * DOM-free so vitest (Node environment, no jsdom) can exercise the
+ * navigation logic directly.
*
- * The UI layer (`src/components/onboarding/tour.tsx`) builds the
- * step list once on mount and then drives navigation through these
- * helpers.
+ * The tour walks every module in the order data → intelligence →
+ * export, teaching each module's value, one hidden capability, and how
+ * it feeds the others. The UI layer (`src/components/onboarding/tour.tsx`)
+ * builds the step list once on mount and then drives navigation through
+ * these helpers.
*
- * Tour stops are anchored to UI targets via `data-tour-id` attributes
- * (NOT class-name selectors — class names change too often during
- * Tailwind tweaks; `data-tour-id` is a stable contract). Every stop
- * declares its `targetId`; the spotlight component looks up the live
- * element via `document.querySelector('[data-tour-id=""]')`.
- *
- * If a target element is missing at runtime (e.g. user is on a
- * resized layout that hides it), the spotlight falls back to a
- * centred-screen tooltip so the tour never blocks indefinitely.
+ * Stops anchor to UI targets via `data-tour-id` attributes (NOT
+ * class-name selectors — class names churn during Tailwind tweaks;
+ * `data-tour-id` is a stable contract). A stop on another route also
+ * carries a `route`; the overlay `router.push`es there, waits for the
+ * anchor to mount, then measures. A missing target at runtime falls
+ * back to a centred-screen tooltip so the tour never blocks.
*/
-/** Stable IDs for every stop in the tour. */
+import type { ModuleKey } from "@/lib/modules/registry";
+
+/** Stable IDs for every stop in the tour, in presentation order. */
export type TourStopId =
- | "tile-strip"
- | "quick-add"
+ | "dashboardOverview"
+ | "quickAdd"
+ | "measurements"
+ | "medications"
+ | "labs"
+ | "illness"
+ | "vorsorge"
+ | "cycle"
+ | "mood"
| "insights"
+ | "coach"
| "integrations"
- | "achievements";
+ | "export"
+ | "achievements"
+ | "wrapUp";
/**
* One step in the tour. The `targetId` matches a `data-tour-id` on a
- * live element somewhere in the app; the title/body i18n keys live
- * under `onboarding.tour.steps..*`.
+ * live element; the title/body i18n keys live under
+ * `onboarding.tour.steps..*`.
*/
export interface TourStop {
id: TourStopId;
/**
* The `data-tour-id` value of the element to anchor the spotlight
- * to. May be `null` if the stop is purely informational and should
- * render in a centred-screen tooltip without a spotlight cutout.
+ * to. `null` for a purely informational centred stop (the wrap-up).
*/
targetId: string | null;
/**
- * Preferred placement of the tooltip relative to the target. The UI
- * layer is allowed to flip this if the target is too close to a
- * viewport edge.
+ * Preferred tooltip placement relative to the target. The UI layer
+ * may flip it if the target is too close to a viewport edge.
*/
placement: "top" | "bottom" | "left" | "right" | "center";
/** i18n key for the step title. */
titleKey: string;
/** i18n key for the step body. */
bodyKey: string;
+ /**
+ * v1.18.6 — the route this stop's anchor lives on. Absent ⇒ the
+ * anchor is on the current page (the wrap-up centred stop). When set,
+ * the overlay navigates there before measuring. `"/"` is explicit for
+ * the two dashboard stops so the resume path can route back from any
+ * page.
+ */
+ route?: string;
+ /**
+ * v1.18.6 — module gate. When set, the stop is dropped unless the
+ * account's resolved module map has the key enabled (default-on:
+ * only an explicit `false` drops it, mirroring the nav gate).
+ */
+ requiresModule?: ModuleKey;
}
/**
- * Build the canonical tour-step list. Caller may inject a feature
- * flag — e.g. `includeAchievements: false` if B4 hasn't shipped yet
- * — and we omit that stop without renumbering.
+ * v1.18.6 — the canonical 15-stop module tour. Each entry is a literal
+ * so the order, anchors, routes and gates are one auditable list.
+ */
+const ALL_STOPS: readonly TourStop[] = [
+ {
+ id: "dashboardOverview",
+ targetId: "dashboard-tile-strip",
+ placement: "bottom",
+ route: "/",
+ titleKey: "onboarding.tour.steps.dashboardOverview.title",
+ bodyKey: "onboarding.tour.steps.dashboardOverview.body",
+ },
+ {
+ id: "quickAdd",
+ targetId: "dashboard-quick-add",
+ placement: "bottom",
+ route: "/",
+ titleKey: "onboarding.tour.steps.quickAdd.title",
+ bodyKey: "onboarding.tour.steps.quickAdd.body",
+ },
+ {
+ id: "measurements",
+ targetId: "measurements-hero",
+ placement: "bottom",
+ route: "/measurements",
+ titleKey: "onboarding.tour.steps.measurements.title",
+ bodyKey: "onboarding.tour.steps.measurements.body",
+ },
+ {
+ id: "medications",
+ targetId: "medications-hero",
+ placement: "bottom",
+ route: "/medications",
+ requiresModule: "medications",
+ titleKey: "onboarding.tour.steps.medications.title",
+ bodyKey: "onboarding.tour.steps.medications.body",
+ },
+ {
+ id: "labs",
+ targetId: "labs-hero",
+ placement: "bottom",
+ route: "/labs",
+ requiresModule: "labs",
+ titleKey: "onboarding.tour.steps.labs.title",
+ bodyKey: "onboarding.tour.steps.labs.body",
+ },
+ {
+ id: "illness",
+ targetId: "illness-hero",
+ placement: "bottom",
+ route: "/illness",
+ requiresModule: "illness",
+ titleKey: "onboarding.tour.steps.illness.title",
+ bodyKey: "onboarding.tour.steps.illness.body",
+ },
+ {
+ id: "vorsorge",
+ targetId: "vorsorge-hero",
+ placement: "bottom",
+ route: "/vorsorge",
+ titleKey: "onboarding.tour.steps.vorsorge.title",
+ bodyKey: "onboarding.tour.steps.vorsorge.body",
+ },
+ {
+ id: "cycle",
+ targetId: "cycle-hero",
+ placement: "bottom",
+ route: "/cycle",
+ requiresModule: "cycle",
+ titleKey: "onboarding.tour.steps.cycle.title",
+ bodyKey: "onboarding.tour.steps.cycle.body",
+ },
+ {
+ id: "mood",
+ targetId: "mood-hero",
+ placement: "bottom",
+ route: "/mood",
+ requiresModule: "mood",
+ titleKey: "onboarding.tour.steps.mood.title",
+ bodyKey: "onboarding.tour.steps.mood.body",
+ },
+ {
+ id: "insights",
+ targetId: "insights-hero",
+ placement: "bottom",
+ route: "/insights",
+ requiresModule: "insights",
+ titleKey: "onboarding.tour.steps.insights.title",
+ bodyKey: "onboarding.tour.steps.insights.body",
+ },
+ {
+ id: "coach",
+ targetId: "coach-hero",
+ placement: "bottom",
+ route: "/coach",
+ requiresModule: "coach",
+ titleKey: "onboarding.tour.steps.coach.title",
+ bodyKey: "onboarding.tour.steps.coach.body",
+ },
+ {
+ id: "integrations",
+ targetId: "integrations-hero",
+ placement: "bottom",
+ route: "/settings/integrations",
+ titleKey: "onboarding.tour.steps.integrations.title",
+ bodyKey: "onboarding.tour.steps.integrations.body",
+ },
+ {
+ id: "export",
+ targetId: "export-hero",
+ placement: "bottom",
+ route: "/settings/advanced",
+ titleKey: "onboarding.tour.steps.export.title",
+ bodyKey: "onboarding.tour.steps.export.body",
+ },
+ {
+ id: "achievements",
+ targetId: "achievements-hero",
+ placement: "bottom",
+ route: "/achievements",
+ requiresModule: "achievements",
+ titleKey: "onboarding.tour.steps.achievements.title",
+ bodyKey: "onboarding.tour.steps.achievements.body",
+ },
+ {
+ id: "wrapUp",
+ targetId: null,
+ placement: "center",
+ titleKey: "onboarding.tour.steps.wrapUp.title",
+ bodyKey: "onboarding.tour.steps.wrapUp.body",
+ },
+];
+
+/**
+ * A partial map of `ModuleKey → enabled` — the `modules` field from
+ * `GET /api/auth/me`. Default-on: a missing key or `true` keeps the
+ * stop; only an explicit `false` drops it.
+ */
+export type TourModuleMap = Partial>;
+
+/**
+ * Build the resolved tour-step list for the given module map. Stops
+ * whose `requiresModule` resolves to `false` are dropped without
+ * renumbering the rest.
+ *
+ * `filterToStop` narrows the list to a single stop — the per-module
+ * "Diese Tour zeigen" re-entry shows just that module's card. An
+ * unknown / module-disabled stop id yields an empty list (caller
+ * renders nothing).
*/
export function buildTourStops(opts?: {
- includeAchievements?: boolean;
+ modules?: TourModuleMap;
+ filterToStop?: TourStopId;
}): TourStop[] {
- const includeAchievements = opts?.includeAchievements ?? true;
-
- const stops: TourStop[] = [
- {
- id: "tile-strip",
- targetId: "dashboard-tile-strip",
- placement: "bottom",
- titleKey: "onboarding.tour.steps.tileStrip.title",
- bodyKey: "onboarding.tour.steps.tileStrip.body",
- },
- {
- id: "quick-add",
- targetId: "dashboard-quick-add",
- placement: "bottom",
- titleKey: "onboarding.tour.steps.quickAdd.title",
- bodyKey: "onboarding.tour.steps.quickAdd.body",
- },
- {
- id: "insights",
- targetId: "nav-insights",
- placement: "right",
- titleKey: "onboarding.tour.steps.insights.title",
- bodyKey: "onboarding.tour.steps.insights.body",
- },
- {
- id: "integrations",
- // The integrations link sits behind the Settings sub-menu on
- // some viewports — we anchor to the Settings nav item instead
- // so the spotlight is always visible without forcing a hover.
- targetId: "nav-settings",
- placement: "right",
- titleKey: "onboarding.tour.steps.integrations.title",
- bodyKey: "onboarding.tour.steps.integrations.body",
- },
- ];
-
- if (includeAchievements) {
- stops.push({
- id: "achievements",
- targetId: "nav-achievements",
- placement: "right",
- titleKey: "onboarding.tour.steps.achievements.title",
- bodyKey: "onboarding.tour.steps.achievements.body",
- });
+ const modules = opts?.modules;
+ const gated = ALL_STOPS.filter(
+ (s) => !s.requiresModule || modules?.[s.requiresModule] !== false,
+ );
+ if (opts?.filterToStop) {
+ return gated.filter((s) => s.id === opts.filterToStop);
}
-
- return stops;
+ return gated;
}
/**
@@ -116,29 +247,39 @@ export function buildTourStops(opts?: {
* helper returns a fresh state instead of mutating in place.
*/
export interface TourState {
- /** Index into the steps array. May equal `steps.length` to indicate the tour is complete. */
+ /** Index into the steps array. May equal `steps.length` when complete. */
index: number;
/** Cached step list — handy for the renderer. */
steps: TourStop[];
/**
- * `null` while the tour is still running, `"completed"` once the
- * user reached the end and clicked Done, `"skipped"` if they hit
- * Skip on any step. Both terminal states cause the same DB write
- * (`onboardingTourCompleted = true`); we keep them distinct purely
- * so analytics can tell them apart in audit logs.
+ * `null` while running, `"completed"` once the user reached the end
+ * and clicked Done, `"skipped"` if they hit Skip on any step.
*/
outcome: "completed" | "skipped" | null;
}
-/** Initial state at the first step. */
-export function initTourState(steps: TourStop[]): TourState {
- return { index: 0, steps, outcome: null };
+/**
+ * Initial state. `resumeFromStopId` (v1.18.6) seeds the index from a
+ * persisted resume point — if the id is present in the resolved list,
+ * the tour opens there; otherwise it starts at the first stop (e.g. the
+ * resume stop's module was disabled since the checkpoint was written).
+ */
+export function initTourState(
+ steps: TourStop[],
+ resumeFromStopId?: string | null,
+): TourState {
+ let index = 0;
+ if (resumeFromStopId) {
+ const found = steps.findIndex((s) => s.id === resumeFromStopId);
+ if (found >= 0) index = found;
+ }
+ return { index, steps, outcome: null };
}
/**
- * Advance to the next step. If we're already on the last step, mark
- * the tour `completed`. Calling on a terminal state is a no-op so
- * the renderer can fire-and-forget on rapid double-clicks.
+ * Advance to the next step. On the last step, mark `completed`. A
+ * no-op on a terminal state so the renderer can fire-and-forget on
+ * rapid double-clicks.
*/
export function nextStep(state: TourState): TourState {
if (state.outcome !== null) return state;
@@ -151,8 +292,7 @@ export function nextStep(state: TourState): TourState {
/**
* Step backwards. Pinned at index 0 — the first step has no Back
- * button visually; this helper just no-ops to keep the keyboard
- * shortcut (LeftArrow) safe.
+ * button visually; this keeps the LeftArrow shortcut safe.
*/
export function prevStep(state: TourState): TourState {
if (state.outcome !== null) return state;
@@ -187,3 +327,34 @@ export function stepCounter(state: TourState): {
total: state.steps.length,
};
}
+
+/**
+ * v1.18.6 — derive the persisted progress checkpoint from the live
+ * navigation state. Pure so the overlay can fire-and-forget it without
+ * touching the DOM. `completedStopIds` accumulates every stop the user
+ * has reached up to (and including) the current index; `lastStopId` is
+ * the resume point.
+ */
+export function deriveProgress(state: TourState): {
+ lastStopId: string | null;
+ completedStopIds: string[];
+ status: "in_progress" | "completed" | "skipped";
+} {
+ const status =
+ state.outcome === "completed"
+ ? "completed"
+ : state.outcome === "skipped"
+ ? "skipped"
+ : "in_progress";
+ const reachedCount =
+ state.outcome !== null
+ ? state.steps.length
+ : Math.min(state.index + 1, state.steps.length);
+ const completedStopIds = state.steps.slice(0, reachedCount).map((s) => s.id);
+ const current = currentStop(state);
+ return {
+ lastStopId: current?.id ?? null,
+ completedStopIds,
+ status,
+ };
+}
From 33fec28b297a06836a3176249623ecbc1257f8f7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 19:49:37 +0200
Subject: [PATCH 34/71] feat(onboarding): per-module tour re-entry +
consolidated replay
Add a 'Diese Tour zeigen' affordance to every module header that opens the
tour narrowed to that module's card in place, plus additive data-tour
anchors (measurements/medications/labs/illness/vorsorge/cycle/mood/
insights/coach/integrations/export/achievements) so the cross-page walk
has a real target on each page. Consolidate the full-tour replay onto a
single Settings -> Advanced card, relabel it, and drop the duplicate from
About. The replay reset clears the stored progress so it restarts cleanly.
---
messages/de.json | 4 +-
messages/en.json | 4 +-
messages/es.json | 4 +-
messages/fr.json | 4 +-
messages/it.json | 4 +-
messages/pl.json | 4 +-
src/app/achievements/page.tsx | 23 +++-
src/app/admin/[section]/renderer.tsx | 6 +-
src/app/coach/page.tsx | 29 +++--
src/app/labs/page.tsx | 7 +-
src/app/measurements/page.tsx | 23 ++--
src/app/medications/page.tsx | 7 +-
src/app/mood/page.tsx | 7 +-
src/components/cycle/cycle-view.tsx | 23 ++--
src/components/dashboard/dashboard-header.tsx | 3 +
src/components/illness/illness-view.tsx | 23 ++--
.../insights/insights-tab-strip.tsx | 6 +
.../vorsorge-section.tsx | 3 +
.../onboarding/module-tour-trigger.tsx | 53 ++++++++
.../settings/__tests__/sections.test.tsx | 35 ++---
src/components/settings/about-section.tsx | 121 +-----------------
.../settings/gesundheitsakte-section.tsx | 8 +-
.../settings/integrations-section.tsx | 9 +-
src/lib/onboarding/tour-state.ts | 2 +-
24 files changed, 202 insertions(+), 210 deletions(-)
create mode 100644 src/components/onboarding/module-tour-trigger.tsx
diff --git a/messages/de.json b/messages/de.json
index 83131c9e2..d67ae50ec 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4621,9 +4621,7 @@
"changelog": "Changelog",
"docs": "Dokumentation",
"linksHeading": "Quellen & Dokumentation",
- "newerAvailable": "Neue Version: {tag}",
- "tourReplay": "Tour ansehen",
- "tourReplayHint": "Zeige die Dashboard-Tour — praktisch, falls du sie beim ersten Besuch übersprungen hast."
+ "newerAvailable": "Neue Version: {tag}"
},
"testConnection": {
"test": "Verbindung testen",
diff --git a/messages/en.json b/messages/en.json
index 092fe42e9..79fe8ba31 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4621,9 +4621,7 @@
"changelog": "Changelog",
"docs": "Documentation",
"linksHeading": "Sources & docs",
- "newerAvailable": "Newer version: {tag}",
- "tourReplay": "Replay the tour",
- "tourReplayHint": "Watch the dashboard walkthrough — handy if you skipped it on first visit."
+ "newerAvailable": "Newer version: {tag}"
},
"testConnection": {
"test": "Test connection",
diff --git a/messages/es.json b/messages/es.json
index 2110b8cd6..e979134af 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4621,9 +4621,7 @@
"changelog": "Changelog",
"docs": "Documentación",
"linksHeading": "Fuentes y documentación",
- "newerAvailable": "Nueva versión: {tag}",
- "tourReplay": "Volver a ver el recorrido",
- "tourReplayHint": "Mira el recorrido del panel — útil si lo saltaste en la primera visita."
+ "newerAvailable": "Nueva versión: {tag}"
},
"testConnection": {
"test": "Probar conexión",
diff --git a/messages/fr.json b/messages/fr.json
index 8f6e5a3d4..ccb1c3e92 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4621,9 +4621,7 @@
"changelog": "Changelog",
"docs": "Documentation",
"linksHeading": "Sources et documentation",
- "newerAvailable": "Nouvelle version : {tag}",
- "tourReplay": "Revoir la visite",
- "tourReplayHint": "Voir la visite guidée du tableau de bord — pratique si tu l'as sautée à la première visite."
+ "newerAvailable": "Nouvelle version : {tag}"
},
"testConnection": {
"test": "Tester la connexion",
diff --git a/messages/it.json b/messages/it.json
index 2f80c5675..3aea8285f 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4621,9 +4621,7 @@
"changelog": "Changelog",
"docs": "Documentazione",
"linksHeading": "Fonti e documentazione",
- "newerAvailable": "Nuova versione: {tag}",
- "tourReplay": "Rivedi il tour",
- "tourReplayHint": "Guarda il tour della dashboard — utile se l'hai saltato alla prima visita."
+ "newerAvailable": "Nuova versione: {tag}"
},
"testConnection": {
"test": "Prova connessione",
diff --git a/messages/pl.json b/messages/pl.json
index 2c637227b..41f7b38be 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4621,9 +4621,7 @@
"changelog": "Changelog",
"docs": "Dokumentacja",
"linksHeading": "Źródła i dokumentacja",
- "newerAvailable": "Nowa wersja: {tag}",
- "tourReplay": "Obejrzyj samouczek",
- "tourReplayHint": "Zobacz przewodnik po panelu — przydatne, jeśli pominąłeś go przy pierwszej wizycie."
+ "newerAvailable": "Nowa wersja: {tag}"
},
"testConnection": {
"test": "Testuj połączenie",
diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx
index 513927772..f0a9448c3 100644
--- a/src/app/achievements/page.tsx
+++ b/src/app/achievements/page.tsx
@@ -34,6 +34,7 @@ import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
+import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
import { formatDate } from "@/lib/format";
import { useAchievementsQuery } from "@/lib/queries/use-achievements-query";
import {
@@ -317,13 +318,21 @@ export default function AchievementsPage() {
return (
-
-
- {t("achievements.title")}
-
-
- {t("achievements.subtitle")}
-
+
+
+
+ {t("achievements.title")}
+
+
+ {t("achievements.subtitle")}
+
+
+
+
+
diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx
index 8bd9e34be..aeea25435 100644
--- a/src/app/admin/[section]/renderer.tsx
+++ b/src/app/admin/[section]/renderer.tsx
@@ -194,9 +194,9 @@ export function AdminSectionRenderer({
// v1.4.36 W4e — About section reused as-is from the settings
// surface. The component owns its own heading + cards layout
// so no SectionFrame wrapper.
- // v1.18.1 E5 — hide the onboarding "Replay tour" card on the admin
- // surface; it's a user-facing affordance with no admin-side use.
- return ;
+ // v1.18.6 — the About surface no longer carries the tour-replay card
+ // (consolidated onto Settings → Advanced), so there's nothing to hide.
+ return ;
default:
slug satisfies never;
return null;
diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx
index 23363d12e..d8c0d5402 100644
--- a/src/app/coach/page.tsx
+++ b/src/app/coach/page.tsx
@@ -6,6 +6,7 @@ import { Minimize2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation";
+import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
@@ -61,6 +62,7 @@ export default function CoachPage() {
return (
diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx
index 8ef9835e3..98196c102 100644
--- a/src/app/labs/page.tsx
+++ b/src/app/labs/page.tsx
@@ -22,6 +22,7 @@ import { useMounted } from "@/hooks/use-mounted";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
+import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
export default function LabsPage() {
const { isAuthenticated, isLoading } = useAuth();
@@ -63,7 +64,10 @@ export default function LabsPage() {
-
+
{t("labs.title")}
@@ -71,6 +75,7 @@ export default function LabsPage() {
+ setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx
index 1689ed852..7e72ff863 100644
--- a/src/app/measurements/page.tsx
+++ b/src/app/measurements/page.tsx
@@ -16,6 +16,7 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Plus, Loader2 } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "@/lib/i18n/context";
+import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
export default function MeasurementsPage() {
const { isAuthenticated, isLoading } = useAuth();
@@ -110,7 +111,10 @@ export default function MeasurementsPage() {
-
+
{t("measurements.title")}
{/* v1.4.34 IW-G — subtitle now stays visible on mobile so
@@ -120,13 +124,16 @@ export default function MeasurementsPage() {
{t("measurements.subtitle")}
{/* v1.4.34 IW-G — subtitle stays visible on mobile so the
@@ -338,6 +342,7 @@ export default function MedicationsPage() {
+
{/* v1.16.11 (#316) — "Alle fälligen einnehmen". Contextual, not a
permanent fixture: it renders only while at least TWO
medications are currently due (a single due dose is the
diff --git a/src/app/mood/page.tsx b/src/app/mood/page.tsx
index 8d959076e..9a8485974 100644
--- a/src/app/mood/page.tsx
+++ b/src/app/mood/page.tsx
@@ -15,6 +15,7 @@ import { Plus, Loader2, Wrench } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useTranslations } from "@/lib/i18n/context";
+import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
export default function MoodPage() {
const { isAuthenticated, isLoading } = useAuth();
@@ -65,7 +66,10 @@ export default function MoodPage() {
-
+
{t("mood.title")}
{/* v1.4.34 IW-G — subtitle stays visible on mobile so the
@@ -75,6 +79,7 @@ export default function MoodPage() {
+
{/* v1.17 — the wrench is the one customize entry point: it
links to /settings/mood, which owns the tag groups, custom
tags, visibility, and picker order. Same glyph, slot (left
diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx
index 35e831be1..381316336 100644
--- a/src/components/cycle/cycle-view.tsx
+++ b/src/components/cycle/cycle-view.tsx
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTranslations } from "@/lib/i18n/context";
+import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
import { cn } from "@/lib/utils";
import { CycleRing } from "./cycle-ring";
import { BbtChart } from "./bbt-chart";
@@ -139,20 +140,26 @@ export function CycleView() {
{/* Desktop: ring (left) + tabs (right). Single column below lg. */}
diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx
index 79aa15331..54ce87021 100644
--- a/src/components/dashboard/dashboard-header.tsx
+++ b/src/components/dashboard/dashboard-header.tsx
@@ -23,6 +23,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTranslations } from "@/lib/i18n/context";
+import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { getHourForTimeZone } from "@/components/dashboard/range-display";
@@ -96,6 +97,8 @@ export function DashboardHeader({
) : null}
+ {/* v1.18.6 — re-enter the guided module tour from the dashboard. */}
+
{/* Customize shortcut to the dashboard-customization settings
(the tile/layout editor at /settings/dashboard). Sits to
the left of the add button as a monochrome ghost icon, with
diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx
index 88d533285..49ffe5b9c 100644
--- a/src/components/illness/illness-view.tsx
+++ b/src/components/illness/illness-view.tsx
@@ -32,6 +32,7 @@ import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
+import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
import { LogDaySheet } from "./log-day-sheet";
import { NewEpisodeSheet } from "./new-episode-sheet";
@@ -256,20 +257,26 @@ export function IllnessView() {
diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx
index 3897468e7..67c74ab16 100644
--- a/src/components/insights/insights-tab-strip.tsx
+++ b/src/components/insights/insights-tab-strip.tsx
@@ -19,6 +19,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { useTranslations } from "@/lib/i18n/context";
+import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
import { cn } from "@/lib/utils";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import {
@@ -656,6 +657,7 @@ function InsightsTabStripImpl({
return (
+ {/* v1.18.6 — per-module tour re-entry, travelling with the
+ top-right control cluster. Anchors to this strip's
+ `insights-hero` and opens the Insights tour card on the spot. */}
+
{/* v1.15.18 — customise cog. Sits immediately LEFT of the
regenerate button and links to the Insights settings section
(overview arrange + pill sort), matching the Dashboard cog
diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx
index eb814c05b..3ec86c63a 100644
--- a/src/components/measurement-reminders/vorsorge-section.tsx
+++ b/src/components/measurement-reminders/vorsorge-section.tsx
@@ -32,6 +32,7 @@ import { useState } from "react";
import { CalendarClock, CheckCircle2, Plus, Settings2 } from "lucide-react";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
+import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
AlertDialog,
@@ -370,6 +371,7 @@ export function VorsorgeSection({
{t("measurementReminders.sectionTitle")}
@@ -379,6 +381,7 @@ export function VorsorgeSection({
+
{addButton}
{wrenchButton}
diff --git a/src/components/onboarding/module-tour-trigger.tsx b/src/components/onboarding/module-tour-trigger.tsx
new file mode 100644
index 000000000..a502cbf16
--- /dev/null
+++ b/src/components/onboarding/module-tour-trigger.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+/**
+ * v1.18.6 — per-module "Diese Tour zeigen" re-entry.
+ *
+ * A small ghost button each module page drops into its header action
+ * cluster. It dispatches the `healthlog:module-tour` window event with
+ * the page's stop id; the shell-level `` picks it up and
+ * opens the spotlight narrowed to that single module card on the
+ * current page (no cross-page navigation, no completion flip).
+ *
+ * Anchors to the page's `data-tour="<…>-hero"` element via the launcher
+ * → overlay path. The button itself carries no anchor — it only fires
+ * the event.
+ */
+
+import { Compass } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { useTranslations } from "@/lib/i18n/context";
+import type { TourStopId } from "@/lib/onboarding/tour-state";
+
+export function ModuleTourTrigger({ stopId }: { stopId: TourStopId }) {
+ const { t } = useTranslations();
+
+ function show() {
+ if (typeof window === "undefined") return;
+ try {
+ window.dispatchEvent(
+ new CustomEvent("healthlog:module-tour", { detail: { stopId } }),
+ );
+ } catch {
+ /* ignore — sandboxed iframes etc. */
+ }
+ }
+
+ return (
+
+
+
+ {t("onboarding.tour.moduleTrigger")}
+
+
+ );
+}
diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx
index 1eddc6cca..50704d6e9 100644
--- a/src/components/settings/__tests__/sections.test.tsx
+++ b/src/components/settings/__tests__/sections.test.tsx
@@ -119,12 +119,13 @@ describe("settings sections — SSR smoke", () => {
expect(html).not.toContain("settings.sections.");
});
- it(" exposes the Restart-tour button (moved from Account)", () => {
- // v1.18.1 (D1) — the "Restart onboarding tour" card moved from Settings →
- // Account into Settings → Advanced (a maintenance/reset action).
+ it(" exposes the single Restart-tour card", () => {
+ // v1.18.1 (D1) — the tour-replay card moved from Account into Advanced.
+ // v1.18.6 — relabelled "Restart module tour" and consolidated as the one
+ // full-tour replay home (the About duplicate was removed).
const html = render();
expect(html).toContain('data-testid="settings-restart-tour"');
- expect(html).toContain("Restart onboarding tour");
+ expect(html).toContain("Restart module tour");
// No raw i18n key leaks.
expect(html).not.toContain("onboarding.tour.");
});
@@ -148,27 +149,13 @@ describe("settings sections — SSR smoke", () => {
expect(html).not.toContain("settings.about.");
});
- it(" exposes a Replay-tour button for the W5 chain gate", () => {
+ it(" no longer carries a tour-replay card (consolidated to Advanced)", () => {
+ // v1.18.6 — the tour replay lives on one home in Settings → Advanced;
+ // the About duplicate was removed. Per-module "Diese Tour zeigen"
+ // affordances cover in-context re-entry.
const html = render();
- // v1.4.47 W5 — the spotlight tour now auto-launches only ≥ 24 h
- // after the wizard finishes (see `shouldAutoLaunchTour` in
- // `components/onboarding/tour-launcher.tsx`). A first-day user who
- // *does* want the tour needs a discoverable manual trigger, and
- // Settings → About is the surface every user can find. Pin both
- // the data-testid and the visible copy so a future refactor can't
- // silently drop the button.
- expect(html).toContain('data-testid="about-replay-tour"');
- expect(html).toContain("Replay the tour");
- // No raw i18n key leaks past the i18n layer.
- expect(html).not.toContain("settings.about.tourReplay");
- });
-
- it(" renders the German Replay-tour label end-to-end", () => {
- // project-voice + i18n parity: the W5 spec explicitly names this
- // surface "Tour ansehen" in German. Lock the resolved copy so a
- // future locale-bundle drift doesn't silently fall back to English.
- const html = render(, "de");
- expect(html).toContain("Tour ansehen");
+ expect(html).not.toContain('data-testid="about-replay-tour"');
+ expect(html).not.toContain("Replay the tour");
});
it(" renders the AI Insights heading", () => {
diff --git a/src/components/settings/about-section.tsx b/src/components/settings/about-section.tsx
index e79e452f8..847c43357 100644
--- a/src/components/settings/about-section.tsx
+++ b/src/components/settings/about-section.tsx
@@ -5,7 +5,6 @@ import { useQuery } from "@tanstack/react-query";
import {
ArrowUpCircle,
BookOpen,
- Compass,
ExternalLink,
GitBranch,
Info,
@@ -15,9 +14,7 @@ import {
import { Button } from "@/components/ui/button";
import { SettingsCardHeader } from "@/components/settings/_card-header";
-import { useAuth } from "@/hooks/use-auth";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
-import { restartOnboardingTour } from "@/lib/onboarding/tour-restart";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
@@ -118,25 +115,9 @@ export function UpdateBadge({
);
}
-export interface AboutSectionProps {
- /**
- * v1.18.1 E5 — suppress the "Replay tour" card. The admin → Über
- * surface reuses this component but the onboarding tour is a
- * user-facing affordance with no place in the admin console, so the
- * admin renderer passes `hideTourReplay`. The Settings → About surface
- * leaves it on.
- */
- hideTourReplay?: boolean;
-}
-
-export function AboutSection({ hideTourReplay = false }: AboutSectionProps = {}) {
+export function AboutSection() {
const { t } = useTranslations();
const fmt = useFormatters();
- // Pull the auth user — only needed so the "Replay the tour" button
- // can stamp a per-user force-launch marker into sessionStorage. The
- // hook is cheap (a cached `/api/auth/me` read) and every settings
- // page already lives behind the same auth boundary.
- const { user } = useAuth();
const { data: version, isLoading } = useQuery({
queryKey: queryKeys.apiVersion(),
@@ -147,45 +128,6 @@ export function AboutSection({ hideTourReplay = false }: AboutSectionProps = {})
staleTime: Infinity,
});
- // v1.4.47 W5 — onboarding chain-gate: the spotlight tour now auto-
- // launches only ≥ 24 h after the wizard finishes (see
- // `shouldAutoLaunchTour` in `components/onboarding/tour-launcher.tsx`).
- // The carousel + tour no longer stack into ~90 s of forced onboarding
- // on first visit. The tradeoff: a first-day user who *does* want the
- // tour needs a discoverable manual trigger. Settings → About is the
- // surface every user can find (every locale puts "About" right on the
- // settings shell), so it carries the replay button next to the
- // sources/docs links. Settings → Account still has its own
- // "Restart onboarding tour" — both buttons share the same flow.
- const [replayingTour, setReplayingTour] = useState(false);
- // v1.4.48 M6c — text + type collapsed into one discriminated state so
- // the two values can never drift (a "success" message rendered in the
- // destructive colour was the latent bug class).
- const [tourFeedback, setTourFeedback] = useState<{
- text: string;
- type: "success" | "error";
- } | null>(null);
-
- async function handleReplayTour() {
- setReplayingTour(true);
- setTourFeedback(null);
- // v1.4.48 M6b — both Settings → Account and Settings → About now
- // delegate to the shared `restartOnboardingTour()` worker so the
- // server flip + force-launch marker + window event live in one
- // place. The helper returns a discriminated result; this surface
- // only owns the translation + feedback rendering.
- const result = await restartOnboardingTour(user?.id);
- if (result.ok) {
- setTourFeedback({
- text: t("onboarding.tour.restartConfirmation"),
- type: "success",
- });
- } else {
- setTourFeedback({ text: t(result.messageKey), type: "error" });
- }
- setReplayingTour(false);
- }
-
// v1.4.36 W4f — the explicit "Check for updates" button is gone;
// only the 24 h auto-check stays. When the auto-check reports
// `newer_available`, a subtle upward-arrow badge appears next to
@@ -364,63 +306,10 @@ export function AboutSection({ hideTourReplay = false }: AboutSectionProps = {})
)}
- {/* v1.4.47 W5 — Replay tour card. The dashboard spotlight
- tour now auto-launches only ≥ 24 h after the wizard finishes,
- so a first-day user who wants to see it still needs a
- discoverable manual trigger. About is the surface every user
- can find (it's the standard "where am I?" stop in the
- settings shell), so the button lives here next to version +
- links. Mirrors the stack-on-mobile / right-align-on-desktop
- contract used by the Account section's "Restart onboarding
- tour" card so the two surfaces feel consistent.
-
- v1.18.1 E5 — hidden on the admin → Über surface (`hideTourReplay`):
- the onboarding tour is a user-facing affordance with no place in
- the admin console. */}
- {!hideTourReplay && (
-
- )}
+ {/* v1.18.6 — the tour-replay card consolidated onto a single home in
+ Settings → Advanced ("Modul-Tour neu starten"). The About surface
+ no longer duplicates it; per-module "Diese Tour zeigen" affordances
+ on every module page cover in-context re-entry. */}
{/* v1.4.36 W4f — the dedicated "Updates" card with the
manual "Check for updates" button is gone. The 24 h auto-
diff --git a/src/components/settings/gesundheitsakte-section.tsx b/src/components/settings/gesundheitsakte-section.tsx
index dfa9d047d..7bf511e54 100644
--- a/src/components/settings/gesundheitsakte-section.tsx
+++ b/src/components/settings/gesundheitsakte-section.tsx
@@ -19,6 +19,7 @@
import { HealthRecordExportPanel } from "@/components/settings/health-record-export-panel";
import { useTranslations } from "@/lib/i18n/context";
+import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
export function GesundheitsakteSection() {
const { t } = useTranslations();
@@ -28,13 +29,18 @@ export function GesundheitsakteSection() {
className="space-y-6"
>
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
-
+
{t("settings.sections.gesundheitsakte.title")}
+ {/* v1.18.6 — guided-tour re-entry for the doctor-report module + a
+ real spotlight anchor (the section h1 is sr-only). */}
+
+
+
diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx
index 6ed4792c8..2bd9fc16c 100644
--- a/src/components/settings/integrations-section.tsx
+++ b/src/components/settings/integrations-section.tsx
@@ -17,6 +17,7 @@
import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel";
import { useTranslations } from "@/lib/i18n/context";
+import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
export {
parseOAuthOutcome,
@@ -32,10 +33,16 @@ export function IntegrationsSection() {
className="space-y-6"
>
{/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
-
+
{t("settings.sections.integrations.title")}
+ {/* v1.18.6 — guided-tour re-entry for the integrations module. The
+ visible affordance also gives the spotlight a real anchor (the
+ section h1 is sr-only). */}
+
+
+
diff --git a/src/lib/onboarding/tour-state.ts b/src/lib/onboarding/tour-state.ts
index adff4cd9f..6106f2b67 100644
--- a/src/lib/onboarding/tour-state.ts
+++ b/src/lib/onboarding/tour-state.ts
@@ -189,7 +189,7 @@ const ALL_STOPS: readonly TourStop[] = [
id: "export",
targetId: "export-hero",
placement: "bottom",
- route: "/settings/advanced",
+ route: "/settings/gesundheitsakte",
titleKey: "onboarding.tour.steps.export.title",
bodyKey: "onboarding.tour.steps.export.body",
},
From 91fd0b438e302e689039c66c86e63861116e5333 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 19:52:40 +0200
Subject: [PATCH 35/71] refactor(onboarding): stabilise the tour progress
callback
Memoise the launcher's progress handler so the overlay checkpoint effect
fires on a real step transition rather than on every launcher re-render.
---
src/components/onboarding/tour-launcher.tsx | 27 +++++++++++++--------
1 file changed, 17 insertions(+), 10 deletions(-)
diff --git a/src/components/onboarding/tour-launcher.tsx b/src/components/onboarding/tour-launcher.tsx
index 4254e0770..c76b6c0d1 100644
--- a/src/components/onboarding/tour-launcher.tsx
+++ b/src/components/onboarding/tour-launcher.tsx
@@ -48,7 +48,7 @@
*/
import { usePathname } from "next/navigation";
-import { useEffect, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
@@ -430,6 +430,17 @@ export function TourLauncher({ ready = true }: TourLauncherProps) {
};
}, []);
+ // v1.18.6 — stable progress callback so the overlay's checkpoint effect
+ // fires on a real step transition, not on every launcher re-render.
+ const handleProgress = useCallback(
+ (progress: { lastStopId: string | null; completedStopIds: string[]; status: "in_progress" | "completed" | "skipped" }) => {
+ void apiPost("/api/onboarding/tour", {
+ progress: { ...progress, updatedAt: new Date().toISOString() },
+ }).catch(() => {});
+ },
+ [],
+ );
+
if (showTour !== true) return null;
// The resolved module map gates which stops appear (default-on) and
@@ -446,15 +457,11 @@ export function TourLauncher({ ready = true }: TourLauncherProps) {
modules={modules}
resumeFromStopId={resumeFromStopId}
filterToStop={singleStop ?? undefined}
- onProgress={(progress) => {
- // Fire-and-forget checkpoint so a mid-tour reload resumes at the
- // right module. Skipped implicitly by the overlay for the
- // single-stop re-entry. A failed write is non-fatal — the resume
- // point is a convenience, not a correctness invariant.
- void apiPost("/api/onboarding/tour", {
- progress: { ...progress, updatedAt: new Date().toISOString() },
- }).catch(() => {});
- }}
+ // Fire-and-forget checkpoint so a mid-tour reload resumes at the
+ // right module. Skipped implicitly by the overlay for the single-
+ // stop re-entry. A failed write is non-fatal — the resume point is
+ // a convenience, not a correctness invariant.
+ onProgress={handleProgress}
onClose={async (outcome) => {
// Optimistic: hide the overlay immediately, persist in the
// background. The flag is idempotent so re-fires are safe;
From 9be30897faa7814876ef1423fd3a194bb82cbc32 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 20:13:25 +0200
Subject: [PATCH 36/71] feat(insights): add a cited reference-range backbone
and richer metric explainers
Introduce src/lib/reference-ranges.ts: a structured, citation-backed
per-metric reference-range backbone (band + source body + year) with a
deterministic four-state placement helper, read by the UI surfaces. Each
band traces to the medical-citations registry, which gains the supporting
sources (ACC/AHA 2017 and ESC 2024 blood pressure, StatPearls pulse
pressure and mean arterial pressure, ESC/ESH 2018 pulse-wave velocity,
FDA 2024 and StatPearls pulse oximetry, J Gen Intern Med 2019 temperature,
RCP NEWS2 and American Lung Association respiratory rate, visceral-fat
imaging literature, AHA 2024 resting heart rate, ESC/NASPE 1996 HRV,
Wilcox 2000, ACOG 651, Phillips 2017 and CDC 2024 sleep).
Blood pressure keeps ESH 2023 as the labelled default; ACC/AHA 2017 and
ESC 2024 appear only as displayed placement context. Enrich the metric
explainers with concise cited reference bands and a general-guidance
tooltip on every metric page: a darker-skin pulse-oximeter over-read
caveat, device-proxy honesty for readiness/recovery/strain/stress, an
HRV day-to-day-noise note, and a resting-heart-rate clarification. Fill
the empty registry normal ranges, reconcile the steps green floor to
8,000, and tighten the body-temperature band below the fever line. Add
free pulse-pressure and mean-arterial-pressure context on the blood-
pressure detail page, plus cycle-physiology and sleep-hygiene copy across
all six locales. Cycle thresholds carry a verification marker pending an
ACOG source check.
---
messages/de.json | 34 +-
messages/en.json | 46 ++-
messages/es.json | 20 +-
messages/fr.json | 20 +-
messages/it.json | 20 +-
messages/pl.json | 20 +-
src/app/insights/blood-pressure/page.tsx | 26 ++
src/components/insights/sub-page-shell.tsx | 35 +-
src/lib/__tests__/reference-ranges.test.ts | 98 ++++++
src/lib/insights/metric-status-registry.ts | 31 +-
src/lib/medical-citations.ts | 287 ++++++++++++++++
src/lib/reference-ranges.ts | 377 +++++++++++++++++++++
12 files changed, 985 insertions(+), 29 deletions(-)
create mode 100644 src/lib/__tests__/reference-ranges.test.ts
create mode 100644 src/lib/reference-ranges.ts
diff --git a/messages/de.json b/messages/de.json
index d9fab76e8..f30923a92 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2395,7 +2395,7 @@
"explainer": {
"trigger": "Was ist {title}?",
"bloodPressureTitle": "Blutdruck",
- "bloodPressureBody": "Der Blutdruck ist die Kraft, die das Blut auf die Gefäßwände ausübt, angegeben als systolisch zu diastolisch (Spitzenwert beim Herzschlag über dem Ruhewert dazwischen). Dauerhaft hohe Werte belasten Herz und Gefäße auf Dauer.",
+ "bloodPressureBody": "Der Blutdruck ist die Kraft, die das Blut auf die Gefäßwände ausübt, angegeben als systolisch zu diastolisch (Spitzenwert beim Herzschlag über dem Ruhewert dazwischen). Dauerhaft hohe Werte belasten Herz und Gefäße auf Dauer. Als allgemeine Orientierung gilt nach europäischer Leitlinie (ESH 2023) unter 120/80 als optimal und ab 140/90 als Bluthochdruck; die US-Leitlinie (ACC/AHA 2017) setzt mit 130–139/80–89 (Stadium 1) eine strengere Grenze. Einordnung immer aus einer ruhigen, sitzenden Messung, nie aus einem einzelnen Wert.",
"pulseTitle": "Puls",
"pulseBody": "Der Puls gibt an, wie oft das Herz pro Minute schlägt. Eine Einzelmessung spiegelt den Moment wider – in Ruhe liegt er meist bei 60–100 Schlägen pro Minute und steigt natürlicherweise bei Bewegung, Stress oder Koffein.",
"weightTitle": "Gewicht",
@@ -2413,7 +2413,7 @@
"hrvTitle": "Herzfrequenzvariabilität (HRV)",
"hrvBody": "Die HRV ist die kleine Schwankung der Zeit zwischen aufeinanderfolgenden Herzschlägen. Eine höhere Variabilität spiegelt meist ein gut erholtes, anpassungsfähiges Nervensystem wider, ein anhaltender Rückgang kann auf Erschöpfung, Stress oder mangelnde Erholung hinweisen.",
"oxygenSaturationTitle": "Sauerstoffsättigung (SpO₂)",
- "oxygenSaturationBody": "Die SpO₂ ist der Prozentsatz der roten Blutkörperchen, die Sauerstoff transportieren. Werte von 95–100 % sind in Ruhe normal; Werte, die dauerhaft unter 90 % liegen, verdienen Aufmerksamkeit, kurze nächtliche Abfälle sind jedoch häufig.",
+ "oxygenSaturationBody": "Die SpO₂ ist der Prozentsatz der roten Blutkörperchen, die Sauerstoff transportieren. Als allgemeine Orientierung sind Werte von 95–100 % in Ruhe normal (StatPearls/NIH 2023); Werte dauerhaft unter 90 % verdienen Aufmerksamkeit, kurze nächtliche Abfälle sind häufig und über ~98 % bringt nichts. Optische Sensoren können die wahre Sättigung bei dunklerer Haut überschätzen (US FDA 2024) — ein einzelner normnaher Wert ist daher keine Entwarnung; bei Sorge warm und ruhig erneut messen.",
"bodyTemperatureTitle": "Körpertemperatur",
"bodyTemperatureBody": "Die Körpertemperatur spiegelt deinen Kernwärmezustand wider, normalerweise um 37 °C mit kleinen Tagesschwankungen. Ein anhaltender Anstieg kann auf eine Infektion hindeuten; nächtliche Messungen am Handgelenk erfassen vor allem relative Änderungen, keinen klinischen Wert.",
"activeEnergyTitle": "Aktive Energie",
@@ -2485,14 +2485,32 @@
"breathingDisturbancesTitle": "Atemaussetzer im Schlaf",
"breathingDisturbancesBody": "Deine Apple Watch kann schätzen, wie oft deine Atmung im Schlaf unterbrochen war. Ein niedriger Index ist besser; ein von Apple als erhöht eingestufter Wert — vor allem über viele Nächte — sollte mit einer Ärztin oder einem Arzt besprochen werden.",
"stepsTitle": "Schritte",
- "stepsBody": "Die Schrittzahl ist die Gesamtzahl der Schritte, die du täglich gehst. Sie ist das einfachste Maß für Alltagsbewegung — eine höhere Tageszahl spiegelt meist einen aktiveren Lebensstil wider, und eine stetige Gewohnheit zählt mehr als ein einzelner großer Tag.",
- "readinessBody": "Die Tagesform ist ein einzelner Tageswert dafür, wie bereit dein Körper für den Tag ist — er verbindet Ruhepuls, Herzfrequenzvariabilität, Schlaf und die Beständigkeit deiner letzten Nächte. Ein höherer Wert heißt erholt und belastbar; ein niedrigerer ist ein sanfter Hinweis, einen Gang zurückzuschalten.",
+ "stepsBody": "Die Schrittzahl ist die Gesamtzahl der Schritte, die du täglich gehst. Sie ist das einfachste Maß für Alltagsbewegung, und eine stetige Gewohnheit zählt mehr als ein einzelner großer Tag. Als allgemeine Orientierung fällt das Sterblichkeitsrisiko deutlich Richtung etwa 8.000 Schritte pro Tag, mit weiterem Nutzen bis rund 12.000 (Saint-Maurice JAMA 2020); bei älteren Menschen liegt der optimale Wert niedriger. Die „10.000“ ist ein Werbeslogan, keine Forschung.",
+ "readinessBody": "Die Tagesform ist ein einzelner Tageswert dafür, wie bereit dein Körper für den Tag ist — er verbindet Ruhepuls, Herzfrequenzvariabilität, Schlaf und die Beständigkeit deiner letzten Nächte. Es ist ein Geräte-Näherungswert, der an deinem eigenen Ausgangswert gemessen wird, keine klinische Messung, und zwischen Geräten nicht vergleichbar. Ein höherer Wert heißt erholt und belastbar; ein niedrigerer ist ein sanfter Hinweis, einen Gang zurückzuschalten.",
"sleepScoreBody": "Dein Schlafscore fasst eine ganze Nacht in einer Zahl zusammen — aus Dauer, Effizienz, Regelmäßigkeit und der Verteilung von Leicht-, Tief- und REM-Schlaf. Achte eher auf das Muster über Wochen als auf einen perfekten Wert in einer einzelnen Nacht.",
- "recoveryBody": "Die Erholung zeigt, wie gut sich dein Körper regeneriert hat — gestützt auf nächtliche Signale wie Herzfrequenzvariabilität und Ruhepuls. Ein hoher Wert ist grünes Licht, mehr zu geben; ein niedrigerer deutet an, dass dein System noch aufbaut.",
- "strainBody": "Die Belastung erfasst, wie viel kardiovaskuläre und körperliche Last du deinem Körper zugemutet hast — von Workouts bis zu einem aktiven Tag auf den Beinen. Sobald genügend Verlauf vorliegt, wird sie an deinem eigenen üblichen Aufwand gemessen, nicht an einem fremden Maßstab.",
- "stressBody": "Stress ist hier eine Schätzung der körperlichen Last auf deinem Nervensystem, vor allem aus Mustern der Herzfrequenzvariabilität abgelesen — nicht daraus, wie du dich bewusst fühlst. Ein niedrigerer Wert deutet auf einen ruhigen, gut regulierten Zustand hin; ein höherer ist eine Einladung, Erholung einzuplanen.",
+ "recoveryBody": "Die Erholung zeigt, wie gut sich dein Körper regeneriert hat — gestützt auf nächtliche Signale wie Herzfrequenzvariabilität und Ruhepuls. Es ist ein geräteabgeleiteter Näherungswert an deinem eigenen Ausgangswert, keine klinische Messung, und zwischen Geräten nicht vergleichbar. Ein hoher Wert ist grünes Licht, mehr zu geben; ein niedrigerer deutet an, dass dein System noch aufbaut.",
+ "strainBody": "Die Belastung erfasst, wie viel kardiovaskuläre und körperliche Last du deinem Körper zugemutet hast — von Workouts bis zu einem aktiven Tag auf den Beinen. Es ist ein geräteabgeleiteter Index, gemessen an deinem eigenen üblichen Aufwand, keine klinische Messung, und der Kennwert ist zwischen Geräten nicht vergleichbar.",
+ "stressBody": "Stress ist hier eine Geräte-Heuristik aus Mustern der Herzfrequenzvariabilität — keine Cortisol-Messung und nicht, wie du dich bewusst fühlst, und auch kein klinischer Stress-Score. Lies ihn an deinem eigenen Ausgangswert; zwischen Geräten ist er nicht vergleichbar. Ein niedrigerer Wert deutet auf einen ruhigen, gut regulierten Zustand hin; ein höherer ist eine Einladung, Erholung einzuplanen.",
"recoveryPageTitle": "Erholung",
- "recoveryPageBody": "Die Erholung fasst die nächtlichen Signale deines Wearables zusammen – Recharge, Belastung, Load sowie deine Herzfrequenz- und Energiewerte des Tages – und zeigt, wie gut sich dein Körper erholt hat. Achte eher auf das Muster über mehrere Tage als auf einen einzelnen Wert; ein schwächerer Wert ist eine Einladung, kürzerzutreten, kein Alarm."
+ "recoveryPageBody": "Die Erholung fasst die nächtlichen Signale deines Wearables zusammen – Recharge, Belastung, Load sowie deine Herzfrequenz- und Energiewerte des Tages – und zeigt, wie gut sich dein Körper erholt hat. Achte eher auf das Muster über mehrere Tage als auf einen einzelnen Wert; ein schwächerer Wert ist eine Einladung, kürzerzutreten, kein Alarm.",
+ "pulsePressureTitle": "Pulsdruck",
+ "pulsePressureBody": "Der Pulsdruck ist die Differenz zwischen deinem systolischen und diastolischen Wert (systolisch minus diastolisch). Als allgemeine Orientierung sind etwa 40 mmHg in Ruhe typisch; eine große Differenz über rund 60 mmHg im mittleren Alter kann auf versteifte Arterien hinweisen, eine kleine Differenz unter etwa 25 mmHg kann mit niedrigem Schlagvolumen einhergehen (StatPearls/NIH 2023). Ein Gesprächsanlass, keine Diagnose.",
+ "meanArterialPressureTitle": "Mittlerer arterieller Druck",
+ "meanArterialPressureBody": "Der mittlere arterielle Druck ist der durchschnittliche Druck, den deine Organe über einen Herzschlag sehen – grob der diastolische Wert plus ein Drittel der Differenz zum systolischen. Als allgemeine Orientierung liegt ein Ruhewert bei etwa 70–100 mmHg (StatPearls/NIH 2023). Die oft genannte Grenze von ~60 mmHg ist ein Konzept der Akutmedizin, kein Alarm für die Selbstmessung.",
+ "cycleEducationBody": "Dein Zyklus durchläuft vier hormongesteuerte Phasen, und seine Länge und Regelmäßigkeit sind ein gut zugängliches Fenster auf die allgemeine Gesundheit. Als allgemeine Orientierung dauern Zyklen bei Erwachsenen typischerweise 21–35 Tage (klinische Referenzen nennen 24–38), mit einer Blutung von rund fünf Tagen (ACOG 2015). Die Lutealphase ist der stabile Anker, daher schätzt man den Eisprung am besten durch Rückrechnen von der nächsten erwarteten Periode – nie über einen festen „Tag 14“. Ein vorhergesagtes fruchtbares Fenster ist eine Schätzung, die mehrere erfasste Zyklen braucht und ungefähr bleibt, und die Basaltemperatur bestätigt den Eisprung im Nachhinein, statt ihn vorherzusagen. Dies ist keine Verhütungsmethode.",
+ "sleepHygieneBody": "Einige evidenzbasierte Gewohnheiten unterstützen den Schlaf: gleichbleibende Zubett- und Aufstehzeiten auch am Wochenende, helles Morgenlicht und gedämpfte Abende, ein kühles, dunkles und ruhiges Zimmer sowie der Verzicht auf Koffein am Nachmittag, späten Alkohol und große späte Mahlzeiten (CDC 2024). Regelmäßigkeit sagt die Gesundheit mindestens so stark voraus wie die Dauer, und die Gesamtschlafzeit eines Wearables ist weit verlässlicher als seine Aufteilung in Tief-, Leicht- und REM-Schlaf.",
+ "referenceGuidance": "Allgemeine Orientierung, keine medizinische Beratung. Referenzbereiche sind Populationswerte, die sich mit Alter, Geschlecht und Erkrankungen verschieben – dein eigener Verlauf über die Zeit führt die Einordnung.",
+ "referenceState": {
+ "within": "Dein letzter Wert liegt im allgemeinen Referenzbereich.",
+ "slightlyOutside": "Dein letzter Wert liegt knapp außerhalb des üblichen Bereichs – behalte den Verlauf im Blick.",
+ "outside": "Dein letzter Wert liegt außerhalb des üblichen Bereichs. Das ist eine Einordnungshilfe, keine Diagnose; besprich ein anhaltendes Muster mit einer Ärztin oder einem Arzt.",
+ "insufficient": "Es gibt noch nicht genug Daten, oder dieses Signal hat keinen festen Populationsbereich – dein eigener Ausgangswert führt die Einordnung."
+ }
+ },
+ "bpDerived": {
+ "pulsePressure": "Der Pulsdruck (die Differenz zwischen deinen Werten) beträgt etwa {value} mmHg – rund 40 ist in Ruhe typisch.",
+ "meanArterialPressure": "Der mittlere arterielle Druck (der Durchschnitt, den deine Organe sehen) liegt bei etwa {value} mmHg, normalerweise um 70–100.",
+ "caveat": "Diese Werte werden als allgemeine Orientierung aus deiner letzten Messung abgeleitet, nicht als Diagnose."
},
"stats": {
"label": "Zusammenfassende Statistik",
diff --git a/messages/en.json b/messages/en.json
index eed712aac..642d77684 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2395,7 +2395,7 @@
"explainer": {
"trigger": "What is {title}?",
"bloodPressureTitle": "Blood pressure",
- "bloodPressureBody": "Blood pressure is the force your blood exerts on the artery walls, written as systolic over diastolic (the peak during a heartbeat over the resting value between beats). Consistently high readings strain the heart and blood vessels over time.",
+ "bloodPressureBody": "Blood pressure is the force your blood exerts on the artery walls, written as systolic over diastolic (the peak during a heartbeat over the resting value between beats). Consistently high readings strain the heart and blood vessels over time. As general guidance, European guidance (ESH 2023) calls under 120/80 optimal and 140/90 or above hypertension; US guidance (ACC/AHA 2017) sets a stricter line, labelling 130–139/80–89 as Stage 1. Categorise from a calm seated reading, never a single number.",
"pulseTitle": "Pulse",
"pulseBody": "Your pulse is how many times your heart beats per minute. A spot reading reflects what you were doing in the moment — at rest it is typically 60–100 beats per minute, and it rises naturally with movement, stress, or caffeine.",
"weightTitle": "Weight",
@@ -2409,19 +2409,19 @@
"medicationsTitle": "Medication adherence",
"medicationsBody": "Adherence is how reliably you take your medication as prescribed, shown here as the share of expected doses you actually logged. Steady intake is often what makes a treatment work as intended.",
"restingHrTitle": "Resting heart rate",
- "restingHrBody": "Resting heart rate is your heart rate when you are completely at rest, usually measured overnight. A lower resting rate generally points to better cardiovascular fitness, and a rising trend can be an early sign of stress or illness.",
+ "restingHrBody": "Resting heart rate is your heart rate when you are completely at rest, usually measured overnight — distinct from your pulse during activity. A lower resting rate generally points to better cardiovascular fitness, and a rising trend can be an early sign of stress or illness. As general guidance, a normal adult resting rate is 60–100 beats per minute (AHA 2024); a trained heart can sit benignly at 40–60.",
"hrvTitle": "Heart rate variability (HRV)",
"hrvBody": "HRV is the tiny variation in time between consecutive heartbeats. Higher variability usually reflects a well-recovered, adaptable nervous system, while a sustained drop can signal fatigue, stress, or insufficient recovery.",
"oxygenSaturationTitle": "Oxygen saturation (SpO₂)",
- "oxygenSaturationBody": "SpO₂ is the percentage of your red blood cells carrying oxygen. Readings of 95–100% are normal at rest; values that stay below 90% warrant attention, though brief nightly dips are common.",
+ "oxygenSaturationBody": "SpO₂ is the percentage of your red blood cells carrying oxygen. As general guidance, readings of 95–100% are normal at rest (StatPearls/NIH 2023); values that stay below 90% warrant attention, though brief nightly dips are common and above ~98% adds no benefit. Optical readers can over-read true saturation in people with darker skin (US FDA 2024), so a single near-normal reading is not a clearance — retake when warm and still if concerned.",
"bodyTemperatureTitle": "Body temperature",
- "bodyTemperatureBody": "Body temperature reflects your core thermal state, normally around 37 °C with small daily swings. A sustained rise can indicate infection, while overnight wrist readings mainly track relative changes rather than a clinical value.",
+ "bodyTemperatureBody": "Body temperature reflects your core thermal state. As general guidance, the population mean is about 36.6 °C — not 37.0 °C — with a normal oral range of roughly 35.7–37.4 °C and a fever from 38.0 °C (J Gen Intern Med 2019). It swings about 0.5 °C across the day, and sites differ by up to ~1 °C, so tag where you measured. Overnight wrist readings track relative change, not a clinical fever value.",
"activeEnergyTitle": "Active energy",
"activeEnergyBody": "Active energy is the calories you burn through movement, on top of the baseline your body uses at rest. It is a direct measure of how physically active your day was.",
"workoutsTitle": "Workouts",
"workoutsBody": "Workouts are the structured exercise sessions your devices record, with type, duration, and energy. Tracking them shows how regular training accumulates over weeks and how it relates to your other health signals.",
"respiratoryRateTitle": "Respiratory rate",
- "respiratoryRateBody": "Respiratory rate is the number of breaths you take per minute. At rest most adults breathe roughly twelve to twenty times a minute; a sustained rise can accompany illness, exertion, or stress.",
+ "respiratoryRateBody": "Respiratory rate is the number of breaths you take per minute. As general guidance, at rest most adults breathe 12–20 times a minute (American Lung Association); it is meaningful only truly at rest or asleep. A sustained rise of about +3 above your own nightly baseline can be an early illness signal — clinical escalation cutoffs are hospital tools, not self-alarms.",
"bodyWaterTitle": "Total body water",
"bodyWaterBody": "Total body water is the share of your body weight made up of fluid, inside and around your cells. It shifts with hydration, food, and activity, so the trend over weeks tells you more than any single reading.",
"boneMassTitle": "Bone mass",
@@ -2433,7 +2433,7 @@
"muscleMassTitle": "Muscle mass",
"muscleMassBody": "Muscle mass is the estimated weight of the muscle in your body. More muscle supports strength, metabolism, and mobility, and it tends to need active effort to preserve as you age.",
"visceralFatTitle": "Visceral fat",
- "visceralFatBody": "Visceral fat is the fat stored deep in the abdomen around your organs. Unlike fat under the skin, a high level is closely linked to heart disease and type 2 diabetes, so lower is generally better.",
+ "visceralFatBody": "Visceral fat is the fat stored deep in the abdomen around your organs. Unlike fat under the skin, a high level is closely linked to heart disease and type 2 diabetes, so lower is generally better. The scale shows a unitless rating (about 1–12 is the healthy range, 13 and up elevated); this is a separate scale from the imaging cm² thresholds clinicians use and is not interchangeable with them.",
"leanBodyMassTitle": "Lean body mass",
"leanBodyMassBody": "Lean body mass is your total weight minus body fat — muscle, bone, organs, and water combined. Tracking it alongside weight shows whether changes come from muscle or from fat.",
"flightsClimbedTitle": "Flights climbed",
@@ -2453,7 +2453,7 @@
"walkingSpeedTitle": "Walking speed",
"walkingSpeedBody": "Walking speed is how fast you move on a level walk. It is a well-studied marker of overall health and mobility, and a steady or rising pace is a good sign.",
"pulseWaveVelocityTitle": "Pulse wave velocity",
- "pulseWaveVelocityBody": "Pulse wave velocity measures how fast the pressure wave from each heartbeat travels along your arteries. A faster wave indicates stiffer arteries, which is linked to higher cardiovascular risk.",
+ "pulseWaveVelocityBody": "Pulse wave velocity measures how fast the pressure wave from each heartbeat travels along your arteries. A faster wave indicates stiffer arteries, which is linked to higher cardiovascular risk. As general guidance, European clinical reference flags carotid-femoral velocity above 10 m/s (ESC/ESH 2018) — but a consumer estimate is a proxy, not interchangeable with that clinical measurement.",
"vascularAgeTitle": "Vascular age",
"vascularAgeBody": "Vascular age estimates how old your arteries behave compared with your actual age, based on arterial stiffness. A vascular age below your real age suggests healthier blood vessels.",
"environmentalAudioTitle": "Environmental sound levels",
@@ -2465,7 +2465,7 @@
"daylightTitle": "Time in daylight",
"daylightBody": "Time in daylight is how long you spend in outdoor light each day. Regular daylight supports a healthy sleep-wake rhythm and mood, and time outdoors is linked to eye health.",
"bloodGlucoseTitle": "Blood glucose",
- "bloodGlucoseBody": "Blood glucose is the amount of sugar in your blood, the body's main energy source. It rises after eating and settles between meals; readings that stay high over time can signal diabetes or prediabetes.",
+ "bloodGlucoseBody": "Blood glucose is the amount of sugar in your blood, the body's main energy source. It rises after eating and settles between meals. As general guidance for adults without diabetes, a fasting reading under 100 mg/dL is normal, 100–125 is prediabetes, and 126 or above falls in the diabetes range (ADA 2024). A single high reading does not diagnose — a clinician confirms with a repeat test, and people managing diabetes use different, clinician-set targets.",
"skinTemperatureTitle": "Skin temperature",
"skinTemperatureBody": "Skin temperature here is the small nightly change in your wrist temperature from your own baseline, not an absolute fever reading. Shifts can track sleep, environment, illness, or menstrual-cycle phase.",
"cardioFitnessTitle": "Cardio fitness (VO₂ max)",
@@ -2485,14 +2485,32 @@
"breathingDisturbancesTitle": "Sleep breathing disturbances",
"breathingDisturbancesBody": "Your Apple Watch can estimate how often your breathing was interrupted during sleep. A lower index is better; a level Apple flags as elevated, especially over many nights, is worth raising with a clinician.",
"stepsTitle": "Steps",
- "stepsBody": "Step count is the total number of steps you take each day. It is the simplest measure of everyday movement — a higher daily count generally reflects a more active lifestyle, and a steady habit matters more than any single big day.",
- "readinessBody": "Readiness is a single daily read on how prepared your body is to take on the day, blending resting heart rate, heart-rate variability, sleep, and how steady your recent nights have been. A higher score means recovered and resourced; a lower one is a gentle cue to ease off.",
+ "stepsBody": "Step count is the total number of steps you take each day. It is the simplest measure of everyday movement, and a steady habit matters more than any single big day. As general guidance, mortality risk falls steeply toward about 8,000 steps a day with continued benefit through roughly 12,000 (Saint-Maurice JAMA 2020); the optimal count is lower for older adults. The '10,000' figure is a marketing slogan, not research.",
+ "readinessBody": "Readiness is a single daily read on how prepared your body is to take on the day, blending resting heart rate, heart-rate variability, sleep, and how steady your recent nights have been. It is a device proxy benchmarked against your own baseline, not a clinical measure, and scores are not comparable across devices. A higher score means recovered and resourced; a lower one is a gentle cue to ease off.",
"sleepScoreBody": "Your sleep score distils a whole night into one number — duration, efficiency, regularity, and the balance of light, deep, and REM. Watch the pattern across weeks rather than chasing a perfect score on any single night.",
- "recoveryBody": "Recovery reflects how well your body has bounced back, drawing on overnight signals like heart-rate variability and resting heart rate. A strong score is a green light to push; a softer one suggests your system is still rebuilding.",
- "strainBody": "Strain captures how much cardiovascular and physical load you have put on your body, from workouts to an active day on your feet. Once you have enough history it is judged against your own typical effort, not someone else's baseline.",
- "stressBody": "Stress here is an estimate of physiological load on your nervous system, read mainly from heart-rate-variability patterns rather than how you consciously feel. A lower score points to a calm, well-regulated state; a higher one is an invitation to build in recovery.",
+ "recoveryBody": "Recovery reflects how well your body has bounced back, drawing on overnight signals like heart-rate variability and resting heart rate. It is a device-derived proxy read against your own baseline, not a clinical measure, and is not comparable across devices. A strong score is a green light to push; a softer one suggests your system is still rebuilding.",
+ "strainBody": "Strain captures how much cardiovascular and physical load you have put on your body, from workouts to an active day on your feet. It is a device-derived index judged against your own typical effort, not a clinical measure, and the headline number is not comparable across devices.",
+ "stressBody": "Stress here is a device heuristic derived from heart-rate-variability patterns, not a cortisol measurement or how you consciously feel — and not a clinical stress score. Read it against your own baseline; it is not comparable across devices. A lower score points to a calm, well-regulated state; a higher one is an invitation to build in recovery.",
"recoveryPageTitle": "Recovery",
- "recoveryPageBody": "Recovery brings together the overnight signals your wearable records — recharge, strain, load and your day's heart-rate and energy totals — to show how well your body has bounced back. Read the pattern across days rather than any single number; a softer reading is an invitation to ease off, not an alarm."
+ "recoveryPageBody": "Recovery brings together the overnight signals your wearable records — recharge, strain, load and your day's heart-rate and energy totals — to show how well your body has bounced back. Read the pattern across days rather than any single number; a softer reading is an invitation to ease off, not an alarm.",
+ "pulsePressureTitle": "Pulse pressure",
+ "pulsePressureBody": "Pulse pressure is the gap between your systolic and diastolic readings (systolic minus diastolic). As general guidance, about 40 mmHg is typical at rest; a wide gap above roughly 60 mmHg in middle age can reflect stiffening arteries, while a narrow gap under about 25 mmHg can accompany low stroke volume (StatPearls/NIH 2023). It is a discussion prompt, not a diagnosis.",
+ "meanArterialPressureTitle": "Mean arterial pressure",
+ "meanArterialPressureBody": "Mean arterial pressure is the average pressure your organs see across a heartbeat, roughly the diastolic plus a third of the gap to systolic. As general guidance, a resting value sits around 70–100 mmHg (StatPearls/NIH 2023). The often-cited ~60 mmHg floor is an acute-care concept, not a consumer self-alarm.",
+ "cycleEducationBody": "Your cycle runs through four phases driven by shifting hormones, and its length and regularity are an accessible window on overall health. As general guidance, adult cycles typically run 21–35 days (clinical references cite 24–38), with bleeding around five days (ACOG 2015). The luteal phase is the steady anchor, so ovulation is best estimated by counting back from your next expected period — never a fixed 'day 14'. A predicted fertile window is an estimate that needs several recorded cycles and stays approximate, and basal body temperature confirms ovulation after the fact rather than predicting it. This is not a contraceptive method.",
+ "sleepHygieneBody": "A few evidence-based habits support sleep: keep consistent bed and wake times including weekends, get bright morning daylight and dim your evenings, keep the room cool, dark and quiet, and avoid afternoon caffeine, late alcohol and large late meals (CDC 2024). Regularity predicts health at least as strongly as duration, and a wearable's total sleep time is far more trustworthy than its deep, light and REM split.",
+ "referenceGuidance": "General guidance, not medical advice. Reference ranges are population anchors that shift with your age, sex and health conditions — your own trend over time leads the read.",
+ "referenceState": {
+ "within": "Your recent value sits within the general reference range.",
+ "slightlyOutside": "Your recent value sits just outside the usual range — worth keeping an eye on the trend.",
+ "outside": "Your recent value sits outside the usual range. This is a placement aid, not a diagnosis; discuss a sustained pattern with a clinician.",
+ "insufficient": "There isn't enough data yet, or this signal has no fixed population range — your own baseline carries the read."
+ }
+ },
+ "bpDerived": {
+ "pulsePressure": "Pulse pressure (the gap between your readings) is about {value} mmHg — roughly 40 is typical at rest.",
+ "meanArterialPressure": "Mean arterial pressure (the average your organs see) is about {value} mmHg, normally around 70–100.",
+ "caveat": "These are derived from your latest reading as general guidance, not a diagnosis."
},
"stats": {
"label": "Summary statistics",
diff --git a/messages/es.json b/messages/es.json
index 2bafb2f5c..fff5381c9 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2492,7 +2492,25 @@
"strainBody": "El esfuerzo capta cuánta carga cardiovascular y física le has impuesto a tu cuerpo, desde los entrenamientos hasta un día activo de pie. Cuando ya hay suficiente historial, se valora frente a tu propio esfuerzo habitual, no frente a un estándar ajeno.",
"stressBody": "El estrés aquí es una estimación de la carga fisiológica sobre tu sistema nervioso, leída sobre todo a partir de patrones de variabilidad de la frecuencia cardíaca y no de cómo te sientes conscientemente. Una puntuación más baja apunta a un estado calmado y bien regulado; una más alta es una invitación a incorporar recuperación.",
"recoveryPageTitle": "Recuperación",
- "recoveryPageBody": "La recuperación reúne las señales nocturnas que registra tu wearable —recarga, esfuerzo, carga y los totales de frecuencia cardiaca y energía del día— para mostrar cómo se ha recuperado tu cuerpo. Fíjate en el patrón a lo largo de los días más que en un solo número; un valor más bajo es una invitación a bajar el ritmo, no una alarma."
+ "recoveryPageBody": "La recuperación reúne las señales nocturnas que registra tu wearable —recarga, esfuerzo, carga y los totales de frecuencia cardiaca y energía del día— para mostrar cómo se ha recuperado tu cuerpo. Fíjate en el patrón a lo largo de los días más que en un solo número; un valor más bajo es una invitación a bajar el ritmo, no una alarma.",
+ "pulsePressureTitle": "Presión de pulso",
+ "pulsePressureBody": "La presión de pulso es la diferencia entre tu valor sistólico y diastólico (sistólico menos diastólico). Como orientación general, unos 40 mmHg es lo típico en reposo; una diferencia amplia por encima de unos 60 mmHg en la mediana edad puede reflejar arterias más rígidas, mientras que una diferencia estrecha por debajo de unos 25 mmHg puede acompañar a un bajo volumen sistólico (StatPearls/NIH 2023). Es un motivo de conversación, no un diagnóstico.",
+ "meanArterialPressureTitle": "Presión arterial media",
+ "meanArterialPressureBody": "La presión arterial media es la presión promedio que ven tus órganos a lo largo de un latido, aproximadamente la diastólica más un tercio de la diferencia con la sistólica. Como orientación general, un valor en reposo ronda los 70–100 mmHg (StatPearls/NIH 2023). El umbral de ~60 mmHg que se suele citar es un concepto de cuidados agudos, no una alarma para el autocontrol.",
+ "cycleEducationBody": "Tu ciclo atraviesa cuatro fases impulsadas por hormonas, y su duración y regularidad son una ventana accesible a la salud general. Como orientación general, los ciclos en personas adultas suelen durar 21–35 días (las referencias clínicas citan 24–38), con un sangrado de unos cinco días (ACOG 2015). La fase lútea es el ancla estable, así que la ovulación se estima mejor contando hacia atrás desde tu próxima regla prevista, nunca con un «día 14» fijo. Una ventana fértil predicha es una estimación que necesita varios ciclos registrados y sigue siendo aproximada, y la temperatura basal confirma la ovulación a posteriori en lugar de predecirla. No es un método anticonceptivo.",
+ "sleepHygieneBody": "Algunos hábitos con respaldo de evidencia favorecen el sueño: mantener horarios constantes de acostarse y levantarse incluso los fines de semana, recibir luz brillante por la mañana y atenuar las noches, una habitación fresca, oscura y silenciosa, y evitar la cafeína por la tarde, el alcohol tardío y las comidas copiosas a última hora (CDC 2024). La regularidad predice la salud al menos tanto como la duración, y el tiempo total de sueño de un wearable es mucho más fiable que su reparto entre sueño profundo, ligero y REM.",
+ "referenceGuidance": "Orientación general, no consejo médico. Los rangos de referencia son valores poblacionales que cambian con tu edad, sexo y condiciones de salud; tu propia tendencia a lo largo del tiempo guía la lectura.",
+ "referenceState": {
+ "within": "Tu valor reciente se sitúa dentro del rango de referencia general.",
+ "slightlyOutside": "Tu valor reciente queda justo fuera del rango habitual; conviene vigilar la tendencia.",
+ "outside": "Tu valor reciente queda fuera del rango habitual. Es una ayuda de ubicación, no un diagnóstico; comenta un patrón sostenido con un profesional sanitario.",
+ "insufficient": "Aún no hay datos suficientes, o esta señal no tiene un rango poblacional fijo; tu propia base guía la lectura."
+ }
+ },
+ "bpDerived": {
+ "pulsePressure": "La presión de pulso (la diferencia entre tus valores) es de unos {value} mmHg; alrededor de 40 es lo típico en reposo.",
+ "meanArterialPressure": "La presión arterial media (el promedio que ven tus órganos) es de unos {value} mmHg, normalmente entre 70 y 100.",
+ "caveat": "Estos valores se derivan de tu última lectura como orientación general, no como diagnóstico."
},
"stats": {
"label": "Estadísticas resumidas",
diff --git a/messages/fr.json b/messages/fr.json
index 591813a41..4b1daec6e 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2492,7 +2492,25 @@
"strainBody": "La charge mesure l'effort cardiovasculaire et physique que vous avez imposé à votre corps, des entraînements à une journée active debout. Dès qu'il y a assez d'historique, elle est évaluée par rapport à votre propre effort habituel, pas à un standard extérieur.",
"stressBody": "Le stress est ici une estimation de la charge physiologique sur votre système nerveux, lue surtout à partir des schémas de variabilité de la fréquence cardiaque et non de ce que vous ressentez consciemment. Un score plus bas indique un état calme et bien régulé ; un score plus élevé invite à prévoir de la récupération.",
"recoveryPageTitle": "Récupération",
- "recoveryPageBody": "La récupération rassemble les signaux nocturnes enregistrés par votre wearable — recharge, effort, charge ainsi que vos totaux de fréquence cardiaque et d'énergie du jour — pour montrer à quel point votre corps a récupéré. Observez la tendance sur plusieurs jours plutôt qu'un seul chiffre ; une valeur plus faible invite à lever le pied, ce n'est pas une alarme."
+ "recoveryPageBody": "La récupération rassemble les signaux nocturnes enregistrés par votre wearable — recharge, effort, charge ainsi que vos totaux de fréquence cardiaque et d'énergie du jour — pour montrer à quel point votre corps a récupéré. Observez la tendance sur plusieurs jours plutôt qu'un seul chiffre ; une valeur plus faible invite à lever le pied, ce n'est pas une alarme.",
+ "pulsePressureTitle": "Pression pulsée",
+ "pulsePressureBody": "La pression pulsée est l'écart entre votre valeur systolique et diastolique (systolique moins diastolique). À titre indicatif général, environ 40 mmHg est typique au repos ; un écart large au-dessus d'environ 60 mmHg à l'âge moyen peut refléter des artères rigidifiées, tandis qu'un écart étroit en dessous d'environ 25 mmHg peut accompagner un faible volume d'éjection (StatPearls/NIH 2023). C'est un sujet de discussion, pas un diagnostic.",
+ "meanArterialPressureTitle": "Pression artérielle moyenne",
+ "meanArterialPressureBody": "La pression artérielle moyenne est la pression moyenne que voient vos organes sur un battement, soit grosso modo la diastolique plus un tiers de l'écart avec la systolique. À titre indicatif général, une valeur au repos se situe autour de 70–100 mmHg (StatPearls/NIH 2023). Le seuil de ~60 mmHg souvent cité est un concept de soins aigus, pas une alarme pour l'autosurveillance.",
+ "cycleEducationBody": "Votre cycle traverse quatre phases pilotées par les hormones, et sa durée et sa régularité sont une fenêtre accessible sur la santé globale. À titre indicatif général, les cycles chez l'adulte durent typiquement 21–35 jours (les références cliniques citent 24–38), avec des règles d'environ cinq jours (ACOG 2015). La phase lutéale est l'ancrage stable, donc l'ovulation s'estime au mieux en comptant à rebours depuis vos prochaines règles prévues, jamais à un « jour 14 » fixe. Une fenêtre fertile prédite est une estimation qui demande plusieurs cycles enregistrés et reste approximative, et la température basale confirme l'ovulation après coup plutôt qu'elle ne la prédit. Ce n'est pas une méthode de contraception.",
+ "sleepHygieneBody": "Quelques habitudes fondées sur les preuves favorisent le sommeil : des heures de coucher et de lever régulières, week-ends compris, une lumière vive le matin et des soirées tamisées, une chambre fraîche, sombre et calme, et éviter la caféine l'après-midi, l'alcool tardif et les repas copieux tard le soir (CDC 2024). La régularité prédit la santé au moins autant que la durée, et le temps de sommeil total d'un wearable est bien plus fiable que sa répartition entre sommeil profond, léger et paradoxal.",
+ "referenceGuidance": "Indication générale, pas un avis médical. Les plages de référence sont des repères de population qui évoluent avec votre âge, votre sexe et vos conditions de santé ; votre propre tendance dans le temps guide la lecture.",
+ "referenceState": {
+ "within": "Votre valeur récente se situe dans la plage de référence générale.",
+ "slightlyOutside": "Votre valeur récente est juste en dehors de la plage habituelle — il vaut la peine de surveiller la tendance.",
+ "outside": "Votre valeur récente est en dehors de la plage habituelle. C'est une aide au repérage, pas un diagnostic ; parlez d'un schéma persistant à un professionnel de santé.",
+ "insufficient": "Il n'y a pas encore assez de données, ou ce signal n'a pas de plage de population fixe — votre propre référence guide la lecture."
+ }
+ },
+ "bpDerived": {
+ "pulsePressure": "La pression pulsée (l'écart entre vos valeurs) est d'environ {value} mmHg ; environ 40 est typique au repos.",
+ "meanArterialPressure": "La pression artérielle moyenne (la moyenne que voient vos organes) est d'environ {value} mmHg, normalement autour de 70–100.",
+ "caveat": "Ces valeurs sont dérivées de votre dernière mesure à titre indicatif général, pas comme un diagnostic."
},
"stats": {
"label": "Statistiques récapitulatives",
diff --git a/messages/it.json b/messages/it.json
index 2a850296e..3eadf6c84 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2492,7 +2492,25 @@
"strainBody": "Lo sforzo misura quanto carico cardiovascolare e fisico hai imposto al tuo corpo, dagli allenamenti a una giornata attiva in piedi. Quando c'è abbastanza storico, viene valutato rispetto al tuo sforzo abituale, non rispetto a uno standard esterno.",
"stressBody": "Lo stress qui è una stima del carico fisiologico sul tuo sistema nervoso, letto soprattutto dagli schemi di variabilità della frequenza cardiaca e non da come ti senti consapevolmente. Un punteggio più basso indica uno stato calmo e ben regolato; uno più alto è un invito a inserire del recupero.",
"recoveryPageTitle": "Recupero",
- "recoveryPageBody": "Il recupero riunisce i segnali notturni registrati dal tuo wearable — ricarica, sforzo, carico e i totali di frequenza cardiaca ed energia della giornata — per mostrare quanto bene il tuo corpo si è ripreso. Guarda l'andamento su più giorni piuttosto che un singolo numero; un valore più basso è un invito a rallentare, non un allarme."
+ "recoveryPageBody": "Il recupero riunisce i segnali notturni registrati dal tuo wearable — ricarica, sforzo, carico e i totali di frequenza cardiaca ed energia della giornata — per mostrare quanto bene il tuo corpo si è ripreso. Guarda l'andamento su più giorni piuttosto che un singolo numero; un valore più basso è un invito a rallentare, non un allarme.",
+ "pulsePressureTitle": "Pressione differenziale",
+ "pulsePressureBody": "La pressione differenziale è la differenza tra il valore sistolico e quello diastolico (sistolica meno diastolica). Come orientamento generale, circa 40 mmHg è tipico a riposo; un divario ampio sopra i 60 mmHg circa nella mezza età può riflettere arterie più rigide, mentre un divario stretto sotto i 25 mmHg circa può accompagnare una bassa gittata sistolica (StatPearls/NIH 2023). È uno spunto di discussione, non una diagnosi.",
+ "meanArterialPressureTitle": "Pressione arteriosa media",
+ "meanArterialPressureBody": "La pressione arteriosa media è la pressione media che i tuoi organi vedono in un battito, all'incirca la diastolica più un terzo del divario con la sistolica. Come orientamento generale, un valore a riposo si aggira sui 70–100 mmHg (StatPearls/NIH 2023). La soglia di ~60 mmHg spesso citata è un concetto di terapia intensiva, non un allarme per l'automisurazione.",
+ "cycleEducationBody": "Il tuo ciclo attraversa quattro fasi guidate dagli ormoni, e la sua durata e regolarità sono una finestra accessibile sulla salute generale. Come orientamento generale, nei adulti i cicli durano tipicamente 21–35 giorni (i riferimenti clinici citano 24–38), con un sanguinamento di circa cinque giorni (ACOG 2015). La fase luteale è l'ancora stabile, quindi l'ovulazione si stima meglio contando a ritroso dalla prossima mestruazione attesa, mai con un «giorno 14» fisso. Una finestra fertile prevista è una stima che richiede più cicli registrati e resta approssimativa, e la temperatura basale conferma l'ovulazione a posteriori anziché prevederla. Non è un metodo contraccettivo.",
+ "sleepHygieneBody": "Alcune abitudini basate sull'evidenza favoriscono il sonno: orari costanti per coricarsi e svegliarsi anche nel weekend, luce intensa al mattino e serate soffuse, una stanza fresca, buia e silenziosa, ed evitare la caffeina nel pomeriggio, l'alcol a tarda ora e i pasti abbondanti a fine giornata (CDC 2024). La regolarità predice la salute almeno quanto la durata, e il tempo di sonno totale di un wearable è molto più affidabile della sua suddivisione tra sonno profondo, leggero e REM.",
+ "referenceGuidance": "Orientamento generale, non un consiglio medico. Gli intervalli di riferimento sono valori di popolazione che cambiano con la tua età, il sesso e le condizioni di salute; il tuo andamento nel tempo guida la lettura.",
+ "referenceState": {
+ "within": "Il tuo valore recente rientra nell'intervallo di riferimento generale.",
+ "slightlyOutside": "Il tuo valore recente è appena fuori dall'intervallo abituale: vale la pena tenere d'occhio l'andamento.",
+ "outside": "Il tuo valore recente è fuori dall'intervallo abituale. È un aiuto alla collocazione, non una diagnosi; parla di un andamento persistente con un medico.",
+ "insufficient": "Non ci sono ancora dati sufficienti, oppure questo segnale non ha un intervallo di popolazione fisso: il tuo riferimento personale guida la lettura."
+ }
+ },
+ "bpDerived": {
+ "pulsePressure": "La pressione differenziale (il divario tra i tuoi valori) è di circa {value} mmHg; circa 40 è tipico a riposo.",
+ "meanArterialPressure": "La pressione arteriosa media (la media che vedono i tuoi organi) è di circa {value} mmHg, normalmente intorno a 70–100.",
+ "caveat": "Questi valori sono derivati dalla tua ultima misurazione come orientamento generale, non come diagnosi."
},
"stats": {
"label": "Statistiche riepilogative",
diff --git a/messages/pl.json b/messages/pl.json
index 345078605..f232aaf25 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2492,7 +2492,25 @@
"strainBody": "Obciążenie ujmuje, ile wysiłku sercowo-naczyniowego i fizycznego nałożyłeś na swoje ciało — od treningów po aktywny dzień na nogach. Gdy zbierze się wystarczająco danych, jest oceniane względem Twojego własnego typowego wysiłku, a nie cudzej miary.",
"stressBody": "Stres jest tu szacunkiem fizjologicznego obciążenia Twojego układu nerwowego, odczytywanym głównie ze wzorców zmienności rytmu serca, a nie z tego, jak się świadomie czujesz. Niższy wynik wskazuje na spokojny, dobrze regulowany stan; wyższy to zachęta, by wpleść regenerację.",
"recoveryPageTitle": "Regeneracja",
- "recoveryPageBody": "Regeneracja łączy nocne sygnały rejestrowane przez Twoje urządzenie – recharge, obciążenie, load oraz dzienne sumy tętna i energii – aby pokazać, jak dobrze Twój organizm się zregenerował. Patrz na wzorzec w ciągu kilku dni, a nie na pojedynczą liczbę; niższy wynik to zaproszenie do zwolnienia tempa, a nie alarm."
+ "recoveryPageBody": "Regeneracja łączy nocne sygnały rejestrowane przez Twoje urządzenie – recharge, obciążenie, load oraz dzienne sumy tętna i energii – aby pokazać, jak dobrze Twój organizm się zregenerował. Patrz na wzorzec w ciągu kilku dni, a nie na pojedynczą liczbę; niższy wynik to zaproszenie do zwolnienia tempa, a nie alarm.",
+ "pulsePressureTitle": "Ciśnienie tętna",
+ "pulsePressureBody": "Ciśnienie tętna to różnica między wartością skurczową a rozkurczową (skurczowe minus rozkurczowe). Jako ogólna wskazówka, około 40 mmHg jest typowe w spoczynku; szeroka różnica powyżej około 60 mmHg w średnim wieku może odzwierciedlać usztywnienie tętnic, a wąska różnica poniżej około 25 mmHg może towarzyszyć niskiej objętości wyrzutowej (StatPearls/NIH 2023). To powód do rozmowy, nie diagnoza.",
+ "meanArterialPressureTitle": "Średnie ciśnienie tętnicze",
+ "meanArterialPressureBody": "Średnie ciśnienie tętnicze to przeciętne ciśnienie, jakie widzą Twoje narządy w trakcie uderzenia serca, w przybliżeniu rozkurczowe plus jedna trzecia różnicy do skurczowego. Jako ogólna wskazówka, wartość spoczynkowa mieści się w okolicy 70–100 mmHg (StatPearls/NIH 2023). Często przywoływany próg ~60 mmHg to pojęcie z medycyny ostrej, nie alarm do samodzielnego pomiaru.",
+ "cycleEducationBody": "Twój cykl przebiega przez cztery fazy sterowane hormonami, a jego długość i regularność to dostępne okno na ogólny stan zdrowia. Jako ogólna wskazówka, u osób dorosłych cykle trwają zwykle 21–35 dni (źródła kliniczne podają 24–38), z krwawieniem około pięciu dni (ACOG 2015). Faza lutealna jest stałą kotwicą, dlatego owulację najlepiej szacować, licząc wstecz od następnej spodziewanej miesiączki, nigdy według stałego „14. dnia”. Przewidziane okno płodne to oszacowanie, które wymaga kilku zarejestrowanych cykli i pozostaje przybliżone, a podstawowa temperatura ciała potwierdza owulację po fakcie, a nie ją przewiduje. To nie jest metoda antykoncepcji.",
+ "sleepHygieneBody": "Kilka opartych na dowodach nawyków wspiera sen: stałe pory kładzenia się i wstawania także w weekendy, jasne światło rano i przyciemnione wieczory, chłodna, ciemna i cicha sypialnia oraz unikanie kofeiny po południu, alkoholu późnym wieczorem i obfitych posiłków przed snem (CDC 2024). Regularność przewiduje zdrowie co najmniej tak silnie jak długość snu, a całkowity czas snu z urządzenia jest znacznie bardziej wiarygodny niż jego podział na sen głęboki, lekki i REM.",
+ "referenceGuidance": "Ogólna wskazówka, nie porada medyczna. Zakresy referencyjne to wartości populacyjne, które zmieniają się wraz z Twoim wiekiem, płcią i stanem zdrowia; to Twój własny trend w czasie prowadzi odczyt.",
+ "referenceState": {
+ "within": "Twoja ostatnia wartość mieści się w ogólnym zakresie referencyjnym.",
+ "slightlyOutside": "Twoja ostatnia wartość jest tuż poza zwykłym zakresem – warto obserwować trend.",
+ "outside": "Twoja ostatnia wartość jest poza zwykłym zakresem. To pomoc w umiejscowieniu, nie diagnoza; omów utrzymujący się wzorzec z lekarzem.",
+ "insufficient": "Nie ma jeszcze wystarczających danych lub ten sygnał nie ma stałego zakresu populacyjnego – odczyt prowadzi Twoja własna wartość bazowa."
+ }
+ },
+ "bpDerived": {
+ "pulsePressure": "Ciśnienie tętna (różnica między Twoimi wartościami) wynosi około {value} mmHg – około 40 jest typowe w spoczynku.",
+ "meanArterialPressure": "Średnie ciśnienie tętnicze (średnia, jaką widzą Twoje narządy) wynosi około {value} mmHg, zwykle w okolicy 70–100.",
+ "caveat": "Te wartości są wyliczone z ostatniego pomiaru jako ogólna wskazówka, nie jako diagnoza."
},
"stats": {
"label": "Statystyki podsumowujące",
diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx
index 7cb330f84..ea184b33c 100644
--- a/src/app/insights/blood-pressure/page.tsx
+++ b/src/app/insights/blood-pressure/page.tsx
@@ -170,6 +170,32 @@ export default function InsightsBlutdruckPage() {
average). */}
+ {/* v1.18.6 — free derived BP context: pulse pressure (SBP − DBP) and
+ mean arterial pressure ((SBP + 2·DBP)/3), computed from the most
+ recent reading. Pure derivation of stored BP — no schema, no extra
+ fetch. Calm, cited, non-alarming. */}
+ {(() => {
+ const sbp = sysSummary?.latest ?? null;
+ const dbp = diaSummary?.latest ?? null;
+ if (sbp == null || dbp == null || sbp <= dbp) return null;
+ const pp = Math.round(sbp - dbp);
+ const map = Math.round((sbp + 2 * dbp) / 3);
+ return (
+
+ );
+ })()}
+
{/* v1.12.4 — Einschätzung is the last content block; the stat strip
(rendered by the shell) closes the spine below it. */}
{t(`insights.subPage.explainer.${explainerMetric}Body`)}
+ {/* v1.18.6 — a discreet general-guidance tooltip travels with
+ every metric definition. The reference bands in the body
+ above are population anchors, not medical advice; this
+ caption pins that framing without crowding the prose. */}
+
+
+
+
+
+
+
+
+ {t("insights.subPage.explainer.referenceGuidance")}
+
+
+
) : null}
{description && !explainerMetric ? (
diff --git a/src/lib/__tests__/reference-ranges.test.ts b/src/lib/__tests__/reference-ranges.test.ts
new file mode 100644
index 000000000..910e80e05
--- /dev/null
+++ b/src/lib/__tests__/reference-ranges.test.ts
@@ -0,0 +1,98 @@
+import { describe, it, expect } from "vitest";
+
+import { CITATIONS } from "../medical-citations";
+import {
+ REFERENCE_RANGES,
+ REFERENCE_METRICS,
+ classifyReference,
+ getReferenceRange,
+ isReferenceMetric,
+ referenceCitation,
+ referenceLabel,
+ type ReferenceMetric,
+} from "../reference-ranges";
+
+describe("reference-ranges backbone", () => {
+ it("every band + conflict + headline resolves a real citation", () => {
+ for (const metric of REFERENCE_METRICS) {
+ const range = REFERENCE_RANGES[metric];
+ expect(CITATIONS[range.referenceId], `${metric} referenceId`).toBeTruthy();
+ expect(range.unit, `${metric} unit`).toBeTruthy();
+ expect(range.guidanceCaveat, `${metric} guidanceCaveat`).toBeTruthy();
+ for (const band of range.bands) {
+ expect(CITATIONS[band.citation], `${metric} band ${band.label}`).toBeTruthy();
+ // A band must pin at least one bound.
+ expect(band.low != null || band.high != null, `${metric} ${band.label}`).toBe(true);
+ }
+ for (const conflict of range.conflicts ?? []) {
+ expect(CITATIONS[conflict.citation], `${metric} conflict`).toBeTruthy();
+ expect(conflict.note).toBeTruthy();
+ }
+ }
+ });
+
+ it("keeps ESH 2023 as the BP headline anchor and surfaces US/EU context", () => {
+ const bp = REFERENCE_RANGES.BLOOD_PRESSURE;
+ expect(bp.referenceId).toBe("ESH_2023_HYPERTENSION");
+ expect(referenceLabel("BLOOD_PRESSURE")).toContain("ESH 2023");
+ // ACC/AHA + ESC 2024 ride only as context, never the headline.
+ const conflictCitations = (bp.conflicts ?? []).map((c) => c.citation);
+ expect(conflictCitations).toContain("ACC_AHA_2017_BP");
+ expect(conflictCitations).toContain("ESC_2024_BP");
+ });
+
+ it("classifies values into the four-state contract", () => {
+ // Within the normal band.
+ expect(classifyReference("RESTING_HEART_RATE", 70)).toBe("within");
+ expect(classifyReference("BLOOD_GLUCOSE", 90)).toBe("within");
+ // Adjacent watch band.
+ expect(classifyReference("BLOOD_GLUCOSE", 110)).toBe("slightly-outside");
+ // Attention band beyond the watch tier.
+ expect(classifyReference("BLOOD_GLUCOSE", 150)).toBe("outside");
+ // SpO2 below the healthy floor is attention.
+ expect(classifyReference("OXYGEN_SATURATION", 88)).toBe("outside");
+ expect(classifyReference("OXYGEN_SATURATION", 98)).toBe("within");
+ });
+
+ it("returns insufficient for absent values and band-less metrics", () => {
+ expect(classifyReference("RESTING_HEART_RATE", null)).toBe("insufficient");
+ expect(classifyReference("RESTING_HEART_RATE", Number.NaN)).toBe("insufficient");
+ // HRV carries no fixed population band — baseline-only.
+ expect(REFERENCE_RANGES.HEART_RATE_VARIABILITY.bands).toHaveLength(0);
+ expect(classifyReference("HEART_RATE_VARIABILITY", 42)).toBe("insufficient");
+ });
+
+ it("steps backbone uses the canonical 8,000 green floor", () => {
+ const steps = REFERENCE_RANGES.STEPS;
+ expect(steps.bands[0].low).toBe(8000);
+ expect(steps.referenceId).toBe("STEPS_SAINT_MAURICE_2020");
+ });
+
+ it("guards + resolvers narrow correctly", () => {
+ expect(isReferenceMetric("BLOOD_PRESSURE")).toBe(true);
+ expect(isReferenceMetric("NOT_A_METRIC")).toBe(false);
+ expect(getReferenceRange("NOT_A_METRIC")).toBeNull();
+ expect(getReferenceRange("HBA1C")).toBe(REFERENCE_RANGES.HBA1C);
+ expect(referenceCitation("BLOOD_PRESSURE")).toBe(
+ CITATIONS.ESH_2023_HYPERTENSION,
+ );
+ });
+
+ it("no commercial brand appears in any band label or caveat", () => {
+ const banned = /apple|withings|oura|whoop|garmin|fitbit|polar|samsung/i;
+ for (const metric of REFERENCE_METRICS) {
+ const range = REFERENCE_RANGES[metric];
+ expect(range.guidanceCaveat).not.toMatch(banned);
+ for (const band of range.bands) expect(band.label).not.toMatch(banned);
+ for (const conflict of range.conflicts ?? []) {
+ expect(conflict.note).not.toMatch(banned);
+ }
+ }
+ });
+
+ it("metric ids stay a closed, non-empty set", () => {
+ expect(REFERENCE_METRICS.length).toBeGreaterThan(10);
+ const ids = new Set(REFERENCE_METRICS);
+ expect(ids.size).toBe(REFERENCE_METRICS.length);
+ });
+});
diff --git a/src/lib/insights/metric-status-registry.ts b/src/lib/insights/metric-status-registry.ts
index bb0ea17a5..f45e7c4dc 100644
--- a/src/lib/insights/metric-status-registry.ts
+++ b/src/lib/insights/metric-status-registry.ts
@@ -155,6 +155,11 @@ const REGISTRY: Record = {
normalRange: { low: 50, high: 100 },
archetype: "physiological-vital",
},
+ // HRV — no universal "normal" band (ESC/NASPE 1996): only the personal
+ // trend is meaningful and overnight wearable RMSSD is not interchangeable
+ // with the clinical 24-h SDNN norms. No `normalRange` by design; the user's
+ // own baseline leads and single-night swings of ~±10% are routine noise
+ // (see `reference-ranges.ts HEART_RATE_VARIABILITY.guidanceCaveat`).
HEART_RATE_VARIABILITY: {
id: "HEART_RATE_VARIABILITY",
measurementType: "HEART_RATE_VARIABILITY",
@@ -163,6 +168,13 @@ const REGISTRY: Record = {
direction: "higher-better",
archetype: "physiological-vital",
},
+ // SpO₂ — healthy room-air 95–100% (StatPearls/NIH 2023). C4 equity note:
+ // optical pulse oximeters can OVER-READ true saturation in people with
+ // darker skin pigmentation (~3× more often than in lighter skin per the
+ // US FDA 2024 review), so a single near-normal reading is not a clearance.
+ // The band is unchanged; the caveat lives in the explainer copy +
+ // `reference-ranges.ts OXYGEN_SATURATION.guidanceCaveat`. Do NOT set a
+ // "higher is always better" goal — above ~98% adds no benefit.
OXYGEN_SATURATION: {
id: "OXYGEN_SATURATION",
measurementType: "OXYGEN_SATURATION",
@@ -181,13 +193,18 @@ const REGISTRY: Record = {
normalRange: { low: 12, high: 20 },
archetype: "physiological-vital",
},
+ // Body temperature — the population oral-equivalent mean is ~36.6 °C, not
+ // 37.0 °C, with a normal band ~35.7–37.4 °C and fever ≥38.0 °C (J Gen
+ // Intern Med systematic review, 2019). C6: the high anchor tightens
+ // 37.5 → 37.2 so the band sits below the fever line; sites differ by up
+ // to ~1 °C, so the read is a coarse placement only.
BODY_TEMPERATURE: {
id: "BODY_TEMPERATURE",
measurementType: "BODY_TEMPERATURE",
displayName: "Body temperature",
unit: "°C",
direction: "target-band",
- normalRange: { low: 36.1, high: 37.5 },
+ normalRange: { low: 36.1, high: 37.2 },
archetype: "physiological-vital",
},
SKIN_TEMPERATURE: {
@@ -215,12 +232,17 @@ const REGISTRY: Record = {
direction: "lower-better",
archetype: "physiological-vital",
},
+ // Pulse-wave velocity — the cf-PWV >10 m/s organ-damage marker is the
+ // European clinical reference (ESC/ESH 2018). A consumer estimate is a
+ // PROXY, not interchangeable with clinical cf-PWV tonometry; the band is
+ // a coarse "below 10 is the reference side" placement only.
PULSE_WAVE_VELOCITY: {
id: "PULSE_WAVE_VELOCITY",
measurementType: "PULSE_WAVE_VELOCITY",
displayName: "Pulse-wave velocity",
unit: "m/s",
direction: "lower-better",
+ normalRange: { low: 0, high: 10 },
archetype: "physiological-vital",
},
VASCULAR_AGE: {
@@ -232,13 +254,18 @@ const REGISTRY: Record = {
archetype: "physiological-vital",
},
// ── activity-fitness ──
+ // Steps — C2/D4 reconcile: the canonical "green" floor is 8,000/day
+ // (Saint-Maurice JAMA 2020: mortality risk falls steeply toward ~8k with
+ // continued benefit through ~12k), aligning this display band with the
+ // resolver + classifier so the cross-surface number stops contradicting.
+ // The "10,000" target is a marketing slogan, not research.
STEPS: {
id: "STEPS",
measurementType: "ACTIVITY_STEPS",
displayName: "Steps",
unit: "steps/day",
direction: "higher-better",
- normalRange: { low: 7000, high: 10000 },
+ normalRange: { low: 8000, high: 15000 },
archetype: "activity-fitness",
},
ACTIVE_ENERGY: {
diff --git a/src/lib/medical-citations.ts b/src/lib/medical-citations.ts
index f7808766e..3372a3b96 100644
--- a/src/lib/medical-citations.ts
+++ b/src/lib/medical-citations.ts
@@ -189,6 +189,293 @@ export const CITATIONS = {
caveat:
"Reference values for an idealised adult; individual variation broad.",
},
+
+ // ── v1.18.6 — reference-range backbone sources ──
+
+ /**
+ * AHA — All About Heart Rate (resting + target heart rates), 2024.
+ * Normal adult resting 60–100 bpm; endurance athletes benignly 40–60.
+ * Consumer-facing orientation; symptomatic concern is the trigger for
+ * care, not the number alone.
+ */
+ AHA_2024_RHR: {
+ id: "aha-2024-rhr",
+ name: "AHA 2024 Heart Rate",
+ year: 2024,
+ url: "https://www.heart.org/en/health-topics/high-blood-pressure/the-facts-about-high-blood-pressure/all-about-heart-rate-pulse",
+ caveat:
+ "General adult band; a trained heart can sit 40–60, and a new low with dizziness/fainting is the concern, not the number alone.",
+ },
+
+ /**
+ * StatPearls/NIH — Pulse Oximetry (2023). Healthy adult room-air SpO₂
+ * 95–100% (bulk 96–99%); mild hypoxaemia 91–94%; clinically significant
+ * <90%. Consumer wrist/finger optical readers are wellness-grade
+ * (±2–4%) and a single low reading is often artefact.
+ */
+ STATPEARLS_PULSE_OX: {
+ id: "statpearls-pulse-ox",
+ name: "StatPearls Pulse Oximetry",
+ year: 2023,
+ url: "https://www.ncbi.nlm.nih.gov/books/NBK470348/",
+ caveat:
+ "Healthy room-air band; consumer optical SpO₂ is ±2–4% and a single low reading is often artefact — sustained runs are the signal.",
+ },
+
+ /**
+ * 2017 ACC/AHA Guideline for the Prevention, Detection, Evaluation and
+ * Management of High Blood Pressure in Adults. Lowers the US disease
+ * line: Normal <120/<80; Elevated 120–129/<80; Stage 1 130–139 or
+ * 80–89; Stage 2 ≥140 or ≥90. Surfaced only as the stricter COMPARISON
+ * anchor next to the shipped ESH default — never the labelled band.
+ */
+ ACC_AHA_2017_BP: {
+ id: "acc-aha-2017-bp",
+ name: "ACC/AHA 2017",
+ year: 2017,
+ url: "https://www.ahajournals.org/doi/10.1161/HYP.0000000000000065",
+ caveat:
+ "US guideline; labels 130–139/80–89 as Stage 1 — a stricter line than the ESH default, shown for placement only.",
+ },
+
+ /**
+ * 2024 ESC Guidelines for the management of elevated blood pressure and
+ * hypertension. Adds an "Elevated" diagnostic band (120–139/70–89) and a
+ * treated SBP target of 120–129. Disease line stays ≥140/90 office.
+ * Displayed as context alongside the ESH 2023 default.
+ */
+ ESC_2024_BP: {
+ id: "esc-2024-bp",
+ name: "ESC 2024",
+ year: 2024,
+ url: "https://academic.oup.com/eurheartj/article/45/38/3912/7741010",
+ caveat:
+ "European; introduces an 'Elevated' 120–139/70–89 band and a treated 120–129 target — context, not the shipped classification.",
+ },
+
+ /**
+ * StatPearls/NCBI — Physiology, Pulse Pressure (NBK482408). PP = SBP −
+ * DBP; ~40 mmHg typical at rest, wide ≳60 mmHg marks large-artery
+ * stiffening, narrow ≲25 mmHg can accompany low stroke volume. A
+ * "discuss with a clinician" signal, not a diagnosis.
+ */
+ STATPEARLS_PULSE_PRESSURE: {
+ id: "statpearls-pulse-pressure",
+ name: "StatPearls Pulse Pressure",
+ year: 2023,
+ url: "https://www.ncbi.nlm.nih.gov/books/NBK482408/",
+ caveat:
+ "Educational physiology reference; PP is a discussion prompt, never a standalone diagnosis.",
+ },
+
+ /**
+ * StatPearls/NCBI — Physiology, Mean Arterial Pressure (NBK538226).
+ * MAP ≈ (SBP + 2·DBP)/3; resting band ~70–100 mmHg, ~60 mmHg the
+ * conventional organ-perfusion floor — an acute-care concept, NOT a
+ * consumer self-alarm threshold.
+ */
+ STATPEARLS_MAP: {
+ id: "statpearls-map",
+ name: "StatPearls Mean Arterial Pressure",
+ year: 2023,
+ url: "https://www.ncbi.nlm.nih.gov/books/NBK538226/",
+ caveat:
+ "The ~60 mmHg floor is an acute-care perfusion concept, not a consumer alarm threshold.",
+ },
+
+ /**
+ * 2018 ESC/ESH Guidelines for the management of arterial hypertension.
+ * Source of the carotid-femoral PWV >10 m/s organ-damage cut-off and
+ * the BP grade bands still widely cited. The PWV number is European;
+ * AHA/ACC do not enshrine an equivalent consumer PWV threshold.
+ */
+ ESC_ESH_2018_PWV: {
+ id: "esc-esh-2018-pwv",
+ name: "ESC/ESH 2018",
+ year: 2018,
+ url: "https://academic.oup.com/eurheartj/article/39/33/3021/5079119",
+ caveat:
+ "cf-PWV >10 m/s is the European clinical reference; consumer PWV / vascular-age estimates are proxies, not clinical cf-PWV.",
+ },
+
+ /**
+ * US FDA — pulse-oximeter accuracy and limitations (Feb 2024 advisory
+ * committee review + safety communication). Oximeters can overestimate
+ * true saturation in people with darker skin, roughly 3× more often
+ * than in lighter-skinned patients (occult hypoxaemia).
+ */
+ FDA_2024_PULSE_OX: {
+ id: "fda-2024-pulse-ox",
+ name: "FDA 2024 Pulse Oximeter Review",
+ year: 2024,
+ url: "https://www.fda.gov/medical-devices/safety-communications/pulse-oximeter-accuracy-and-limitations-fda-safety-communication",
+ caveat:
+ "Documents over-reading in darker skin; a single near-normal reading is not a clearance — confirm when concerned.",
+ },
+
+ /**
+ * "Normal Body Temperature: A Systematic Review" — J Gen Intern Med,
+ * 2019. Population oral-equivalent mean ~36.6 °C (normal ~35.7–37.4 °C),
+ * superseding the conventional 37.0 °C set point. Fever ≥38.0 °C oral.
+ */
+ JGIM_2019_TEMPERATURE: {
+ id: "jgim-2019-temperature",
+ name: "J Gen Intern Med 2019",
+ year: 2019,
+ url: "https://link.springer.com/article/10.1007/s11606-019-05148-7",
+ caveat:
+ "Oral-equivalent mean; sites differ by up to ~1 °C — tag the measurement site before comparing.",
+ },
+
+ /**
+ * Royal College of Physicians — National Early Warning Score 2 (NEWS2),
+ * 2017. Respiratory-rate scoring: highest-risk ≤8 and ≥25/min. A
+ * hospital deterioration tool, not a consumer alarm.
+ */
+ RCP_2017_NEWS2: {
+ id: "rcp-2017-news2",
+ name: "RCP NEWS2 2017",
+ year: 2017,
+ url: "https://www.rcp.ac.uk/improving-care/resources/national-early-warning-score-news-2/",
+ caveat:
+ "Hospital deterioration score; the ≥25/min cutoff is clinical context, not a self-monitoring alarm.",
+ },
+
+ /**
+ * American Lung Association — resting respiratory rate reference
+ * (12–20 breaths/min for healthy adults). Consumer-facing vital-sign
+ * orientation.
+ */
+ ALA_RESPIRATORY_RATE: {
+ id: "ala-respiratory-rate",
+ name: "American Lung Association",
+ year: 2024,
+ url: "https://www.lung.org/lung-health-diseases/how-lungs-work/breathing-rate",
+ caveat:
+ "General adult resting band; rates run higher in children and shift with activity, fever and emotion.",
+ },
+
+ /**
+ * Peer-reviewed visceral-adipose-tissue imaging literature (2014–2025):
+ * CT/MRI/DXA VAT area >~100 cm² increased-risk, >~160 cm² high-risk.
+ * Ethnicity-dependent; the consumer scale "rating" is NOT a cm² value.
+ */
+ VAT_IMAGING_THRESHOLD: {
+ id: "vat-imaging-threshold",
+ name: "VAT imaging literature",
+ year: 2025,
+ url: "https://pubmed.ncbi.nlm.nih.gov/24008002/",
+ caveat:
+ "Imaging cm² thresholds; the consumer scale 'visceral fat rating' is a separate unitless scale, not interchangeable.",
+ },
+
+ /**
+ * Wilcox AJ, Dunson D, Baird DD. "The timing of the 'fertile window' in
+ * the menstrual cycle." BMJ, 2000. The 6-day fertile window ends on
+ * ovulation day; ovulation timing is highly variable even in regular
+ * cyclers.
+ */
+ WILCOX_2000_FERTILE_WINDOW: {
+ id: "wilcox-2000-fertile-window",
+ name: "Wilcox BMJ 2000",
+ year: 2000,
+ url: "https://www.bmj.com/content/321/7271/1259",
+ caveat:
+ "Fertile-window timing is highly variable; calendar estimates approximate and are not a contraceptive method.",
+ },
+
+ /**
+ * ACOG Committee Opinion 651 (2015) — "Menstruation in Girls and
+ * Adolescents: Using the Menstrual Cycle as a Vital Sign." Adult cycle
+ * 24–38 days, bleeding ~5 days (up to ~7).
+ *
+ * NEEDS-VERIFY ACOG — the cycle-length / bleeding-duration figures cited
+ * in the `insights.subPage.explainer.cycleEducationBody` copy, and any
+ * "when to see a clinician" thresholds (cycle <21 / >35, amenorrhea ≥3
+ * months, heavy bleeding >7 days), must be confirmed against the live
+ * ACOG FAQ before shipping that copy to users (automated fetch was
+ * rate-limited during research).
+ */
+ ACOG_CO651_2015: {
+ id: "acog-co651-2015",
+ name: "ACOG CO 651 2015",
+ year: 2015,
+ url: "https://www.acog.org/clinical/clinical-guidance/committee-opinion/articles/2015/12/menstruation-in-girls-and-adolescents-using-the-menstrual-cycle-as-a-vital-sign",
+ caveat:
+ "Cycle as a vital sign; exact clinician-consultation thresholds should be confirmed against the live ACOG FAQ.",
+ },
+
+ /**
+ * Phillips AJK, et al. "Irregular sleep/wake patterns are associated
+ * with poorer academic performance and delayed circadian and sleep/wake
+ * timing." Sci Rep, 2017 — origin of the Sleep Regularity Index (SRI).
+ * UK Biobank (Windred 2023/24) tied higher regularity to lower mortality.
+ */
+ PHILLIPS_2017_SRI: {
+ id: "phillips-2017-sri",
+ name: "Phillips Sci Rep 2017",
+ year: 2017,
+ url: "https://www.nature.com/articles/s41598-017-03171-4",
+ caveat:
+ "Sleep-regularity construct; no universal good/bad threshold — present as a personal trend.",
+ },
+
+ /**
+ * US CDC — About Sleep / tips for better sleep (2024). Evidence-based
+ * sleep-hygiene behaviours (consistent schedule, morning light, dim
+ * evenings, caffeine timing, cool dark room).
+ */
+ CDC_2024_SLEEP: {
+ id: "cdc-2024-sleep",
+ name: "CDC 2024 Sleep",
+ year: 2024,
+ url: "https://www.cdc.gov/sleep/about/index.html",
+ caveat: "General sleep-hygiene guidance; individual needs vary.",
+ },
+
+ /**
+ * WHO — Obesity: preventing and managing the global epidemic (TRS 894),
+ * 2000. The BMI classification still in use: underweight <18.5, normal
+ * 18.5–24.9, overweight 25.0–29.9, obesity ≥30. Asian-population risk
+ * rises at lower cut-offs (WHO 2004).
+ */
+ WHO_2000_BMI: {
+ id: "who-2000-bmi",
+ name: "WHO 2000 BMI",
+ year: 2000,
+ url: "https://www.who.int/publications/i/item/WHO_TRS_894",
+ caveat:
+ "Population screen, not a diagnosis; Asian-population risk rises at lower cut-offs and BMI does not distinguish muscle from fat.",
+ },
+
+ /**
+ * WHO/IDF — Definition and Diagnosis of Diabetes Mellitus and
+ * Intermediate Hyperglycaemia, 2006. Impaired fasting glucose floor of
+ * 110 mg/dL (vs ADA's 100); the diabetes line (≥126) is shared.
+ */
+ WHO_IDF_2006_GLUCOSE: {
+ id: "who-idf-2006-glucose",
+ name: "WHO/IDF 2006",
+ year: 2006,
+ url: "https://www.who.int/publications/i/item/definition-and-diagnosis-of-diabetes-mellitus-and-intermediate-hyperglycaemia",
+ caveat:
+ "Sets the impaired-fasting-glucose floor at 110 mg/dL; the diabetes threshold (≥126) is shared with ADA.",
+ },
+
+ /**
+ * ESC/NASPE Task Force — Heart Rate Variability: Standards of
+ * Measurement, Physiological Interpretation and Clinical Use.
+ * Circulation / Eur Heart J, 1996. Defines SDNN/RMSSD norms and the
+ * prognostic 24-h SDNN bands; day-to-day swings of ~±10% are routine.
+ */
+ ESC_NASPE_1996_HRV: {
+ id: "esc-naspe-1996-hrv",
+ name: "ESC/NASPE HRV Standards 1996",
+ year: 1996,
+ url: "https://www.ahajournals.org/doi/10.1161/01.CIR.93.5.1043",
+ caveat:
+ "Clinical 24-h SDNN norms are NOT interchangeable with an overnight wearable RMSSD; only the personal trend is meaningful.",
+ },
} as const satisfies Record;
export type CitationId = keyof typeof CITATIONS;
diff --git a/src/lib/reference-ranges.ts b/src/lib/reference-ranges.ts
new file mode 100644
index 000000000..88e8c0581
--- /dev/null
+++ b/src/lib/reference-ranges.ts
@@ -0,0 +1,377 @@
+/**
+ * v1.18.6 — citation-backed reference-range backbone.
+ *
+ * A single structured source of truth for the population reference bands
+ * the UI surfaces (explainer captions, metric tiles, the glossary, the
+ * "where your value sits" tooltip). Each metric carries:
+ * - the canonical display unit,
+ * - one or more named `bands` (low/high half-open, with the body + year
+ * that publishes them via a `CitationId`),
+ * - the primary `referenceId` (the body the headline band is sourced to),
+ * - an optional `conflicts[]` of alternative-guideline context (e.g. the
+ * BP US/EU disagreement — shown as placement context, never the
+ * shipped classification),
+ * - a `guidanceCaveat` that frames the band as general guidance.
+ *
+ * This module is intentionally read-only data + pure helpers — no DB, no
+ * LLM, no per-user state. The metric-status registry fills its coarse
+ * `normalRange` from these bands; the BP detail surface reads the PP / MAP
+ * anchors; the explainers read `referenceLabel()` for the cited sentence.
+ *
+ * Every band traces to `src/lib/medical-citations.ts`. No commercial brand
+ * is named anywhere. Ranges are population anchors that shift with age,
+ * sex and condition — the user's own baseline always leads the read; these
+ * are a placement aid, not a diagnosis. Where authoritative bodies
+ * disagree the disagreement is surfaced (`conflicts`), never silently
+ * resolved.
+ */
+import { CITATIONS, type CitationId, citationLabel } from "./medical-citations";
+
+/**
+ * A single named reference band. `low`/`high` are inclusive display
+ * anchors in the metric's unit; either bound may be omitted for an
+ * open-ended band (e.g. "≥7 h", "<100 mg/dL").
+ */
+export interface ReferenceBand {
+ /** Stable, human-readable band label ("Normal", "Elevated", "Fever"). */
+ readonly label: string;
+ readonly low?: number;
+ readonly high?: number;
+ /** The body + year that publishes this band. */
+ readonly citation: CitationId;
+}
+
+/**
+ * An alternative-guideline framing shown as CONTEXT next to the default
+ * band — never the shipped classification. The BP US/EU split is the
+ * canonical use.
+ */
+export interface ReferenceConflict {
+ /** Short note describing the alternative framing (general education). */
+ readonly note: string;
+ readonly citation: CitationId;
+}
+
+export interface ReferenceRange {
+ /** Canonical display unit (matches the metric tile + registry). */
+ readonly unit: string;
+ /** Ordered population bands; the first is the "good"/normal placement. */
+ readonly bands: readonly ReferenceBand[];
+ /** The body the headline band is sourced to (drives the cited sentence). */
+ readonly referenceId: CitationId;
+ /** Alternative-guideline context, shown for placement only. */
+ readonly conflicts?: readonly ReferenceConflict[];
+ /** One-sentence general-guidance frame surfaced under the band. */
+ readonly guidanceCaveat: string;
+}
+
+/**
+ * The reference-metric slugs the backbone covers. Stable string keys (not
+ * the DB enum) so the UI, the registry and the explainer can all read one
+ * vocabulary. A superset of the universal metrics + the cardiovascular
+ * derivations the explainers cite.
+ */
+export type ReferenceMetric =
+ | "BLOOD_PRESSURE"
+ | "PULSE_PRESSURE"
+ | "MEAN_ARTERIAL_PRESSURE"
+ | "PULSE_WAVE_VELOCITY"
+ | "RESTING_HEART_RATE"
+ | "HEART_RATE_VARIABILITY"
+ | "OXYGEN_SATURATION"
+ | "RESPIRATORY_RATE"
+ | "BODY_TEMPERATURE"
+ | "BLOOD_GLUCOSE"
+ | "HBA1C"
+ | "BMI"
+ | "STEPS"
+ | "VISCERAL_FAT"
+ | "SLEEP_DURATION";
+
+export const REFERENCE_RANGES: Record = {
+ // ── Blood pressure — ESH 2023 is the SHIPPED default (do not flip). ──
+ // ACC/AHA 2017 + ESC 2024 are surfaced only as placement CONTEXT.
+ BLOOD_PRESSURE: {
+ unit: "mmHg",
+ bands: [
+ // ESH grade bands (office, mmHg, systolic anchors shown).
+ { label: "Optimal", high: 120, citation: "ESH_2023_HYPERTENSION" },
+ { label: "Normal", low: 120, high: 129, citation: "ESH_2023_HYPERTENSION" },
+ { label: "High-normal", low: 130, high: 139, citation: "ESH_2023_HYPERTENSION" },
+ { label: "Hypertension", low: 140, citation: "ESH_2023_HYPERTENSION" },
+ ],
+ referenceId: "ESH_2023_HYPERTENSION",
+ conflicts: [
+ {
+ note: "ACC/AHA (US) labels 130–139/80–89 as Stage 1 hypertension — a stricter line than the European disease threshold of 140/90.",
+ citation: "ACC_AHA_2017_BP",
+ },
+ {
+ note: "ESC 2024 adds an 'Elevated' band at 120–139/70–89 and a treated target of 120–129.",
+ citation: "ESC_2024_BP",
+ },
+ ],
+ guidanceCaveat:
+ "Categorised from a standardised seated reading or a validated home average — never a single reading. Targets individualise by age and condition.",
+ },
+
+ // Pulse pressure = SBP − DBP. Pure derivation of stored BP.
+ PULSE_PRESSURE: {
+ unit: "mmHg",
+ bands: [
+ { label: "Narrow", high: 25, citation: "STATPEARLS_PULSE_PRESSURE" },
+ { label: "Typical at rest", low: 25, high: 60, citation: "STATPEARLS_PULSE_PRESSURE" },
+ { label: "Wide", low: 60, citation: "STATPEARLS_PULSE_PRESSURE" },
+ ],
+ referenceId: "STATPEARLS_PULSE_PRESSURE",
+ guidanceCaveat:
+ "A wide gap (≳60) in middle age can reflect arterial stiffening; a narrow gap (≲25) can accompany low stroke volume. A discussion prompt, not a diagnosis.",
+ },
+
+ // Mean arterial pressure ≈ (SBP + 2·DBP)/3.
+ MEAN_ARTERIAL_PRESSURE: {
+ unit: "mmHg",
+ bands: [
+ { label: "Resting band", low: 70, high: 100, citation: "STATPEARLS_MAP" },
+ ],
+ referenceId: "STATPEARLS_MAP",
+ guidanceCaveat:
+ "The average pressure your organs see across a heartbeat. The ~60 mmHg floor is an acute-care concept, not a consumer self-alarm.",
+ },
+
+ PULSE_WAVE_VELOCITY: {
+ unit: "m/s",
+ bands: [
+ { label: "Reference", high: 10, citation: "ESC_ESH_2018_PWV" },
+ { label: "Elevated stiffness", low: 10, citation: "ESC_ESH_2018_PWV" },
+ ],
+ referenceId: "ESC_ESH_2018_PWV",
+ guidanceCaveat:
+ "The cf-PWV >10 m/s organ-damage marker is a European clinical reference measured by tonometry; a consumer estimate is a proxy, not interchangeable.",
+ },
+
+ // ── Resting heart rate — uses RESTING_HEART_RATE, never workout pulse. ──
+ // Bands are authored normal-first so `classifyReference` treats the
+ // population-normal band as the "within" placement. The athletic band
+ // (40–60) is benign for a trained heart but sits below the standard
+ // normal range — the caveat copy carries that nuance.
+ RESTING_HEART_RATE: {
+ unit: "bpm",
+ bands: [
+ { label: "Normal adult resting", low: 60, high: 100, citation: "AHA_2024_RHR" },
+ { label: "Athletic (benign)", low: 40, high: 60, citation: "AHA_2024_RHR" },
+ ],
+ referenceId: "AHA_2024_RHR",
+ guidanceCaveat:
+ "Measured at rest, ideally on waking. A trained heart can sit 40–60 bpm; the trend over weeks beats any single reading. Caffeine, stress, fever and medication all shift it.",
+ },
+
+ HEART_RATE_VARIABILITY: {
+ unit: "ms",
+ bands: [],
+ referenceId: "ESC_NASPE_1996_HRV",
+ guidanceCaveat:
+ "There is no universal 'normal' — only your own trend is meaningful. Single-night swings of about ±10% are routine; track a 7-day rolling average and read changes smaller than that as noise.",
+ },
+
+ // ── SpO2 — healthy room-air band + darker-skin over-read caveat (C4). ──
+ OXYGEN_SATURATION: {
+ unit: "%",
+ bands: [
+ { label: "Healthy room air", low: 95, high: 100, citation: "STATPEARLS_PULSE_OX" },
+ { label: "Mild hypoxaemia", low: 91, high: 94, citation: "STATPEARLS_PULSE_OX" },
+ ],
+ referenceId: "STATPEARLS_PULSE_OX",
+ guidanceCaveat:
+ "95–100% is normal at rest; above ~98% adds no benefit. Optical readers can over-read true saturation in people with darker skin, so a single near-normal reading is not a clearance — confirm when warm, still and concerned.",
+ },
+
+ RESPIRATORY_RATE: {
+ unit: "breaths/min",
+ bands: [
+ { label: "Normal resting adult", low: 12, high: 20, citation: "ALA_RESPIRATORY_RATE" },
+ { label: "Worth attention", low: 22, citation: "RCP_2017_NEWS2" },
+ ],
+ referenceId: "ALA_RESPIRATORY_RATE",
+ guidanceCaveat:
+ "Meaningful only truly at rest or asleep. A sustained rise of about +3 above your own nightly baseline can be an early illness signal; clinical escalation cutoffs are hospital tools, not self-alarms.",
+ },
+
+ // ── Body temperature — evidence-based 36.6 °C mean, fever ≥38 (C6). ──
+ BODY_TEMPERATURE: {
+ unit: "°C",
+ bands: [
+ { label: "Normal oral range", low: 35.7, high: 37.4, citation: "JGIM_2019_TEMPERATURE" },
+ { label: "Fever", low: 38.0, citation: "JGIM_2019_TEMPERATURE" },
+ ],
+ referenceId: "JGIM_2019_TEMPERATURE",
+ guidanceCaveat:
+ "The population mean is ~36.6 °C, not 37.0 °C, and swings ~0.5 °C across the day. Sites differ by up to ~1 °C, so tag the measurement site. Wearable wrist readings track relative change, not a fever value.",
+ },
+
+ // ── Glucose — GENERAL non-diabetic bands only (diabetes opt-in = W6). ──
+ BLOOD_GLUCOSE: {
+ unit: "mg/dL",
+ bands: [
+ { label: "Normal fasting", high: 100, citation: "ADA_2024_GLYCEMIC" },
+ { label: "Prediabetes (fasting)", low: 100, high: 125, citation: "ADA_2024_GLYCEMIC" },
+ { label: "Diabetes range (fasting)", low: 126, citation: "ADA_2024_GLYCEMIC" },
+ ],
+ referenceId: "ADA_2024_GLYCEMIC",
+ conflicts: [
+ {
+ note: "WHO/IDF set the prediabetes fasting floor at 110 mg/dL rather than 100; the diabetes line (≥126) is shared.",
+ citation: "WHO_IDF_2006_GLUCOSE",
+ },
+ ],
+ guidanceCaveat:
+ "Fasting bands for adults without diabetes. A single high reading does not diagnose — a clinician confirms with a repeat test. People managing diabetes use different, clinician-set targets.",
+ },
+
+ HBA1C: {
+ unit: "%",
+ bands: [
+ { label: "Normal", high: 5.7, citation: "ADA_2024_GLYCEMIC" },
+ { label: "Prediabetes", low: 5.7, high: 6.4, citation: "ADA_2024_GLYCEMIC" },
+ { label: "Diabetes", low: 6.5, citation: "ADA_2024_GLYCEMIC" },
+ ],
+ referenceId: "ADA_2024_GLYCEMIC",
+ guidanceCaveat:
+ "Reflects the average glucose over ~2–3 months. Unreliable with altered red-cell turnover; when discordant with glucose, glucose takes precedence.",
+ },
+
+ BMI: {
+ unit: "kg/m²",
+ bands: [
+ { label: "Underweight", high: 18.5, citation: "WHO_2020_PA" },
+ { label: "Normal", low: 18.5, high: 24.9, citation: "WHO_2020_PA" },
+ { label: "Overweight", low: 25.0, high: 29.9, citation: "WHO_2020_PA" },
+ { label: "Obesity", low: 30.0, citation: "WHO_2020_PA" },
+ ],
+ referenceId: "WHO_2000_BMI",
+ guidanceCaveat:
+ "A population-level screen, not a diagnosis — it does not distinguish muscle from fat or where fat sits. Asian-population risk rises at lower cut-offs.",
+ },
+
+ // ── Steps — canonical green floor = 8,000 (C2/D4 reconcile). ──
+ STEPS: {
+ unit: "steps/day",
+ bands: [
+ { label: "Higher mortality benefit", low: 8000, citation: "STEPS_SAINT_MAURICE_2020" },
+ ],
+ referenceId: "STEPS_SAINT_MAURICE_2020",
+ guidanceCaveat:
+ "Mortality risk falls steeply toward ~8,000 steps/day with continued benefit through ~12,000; the optimal count is lower for older adults. The '10,000' figure is a marketing slogan, not research.",
+ },
+
+ VISCERAL_FAT: {
+ unit: "rating",
+ bands: [
+ { label: "Healthy rating", low: 1, high: 12, citation: "VAT_IMAGING_THRESHOLD" },
+ { label: "Elevated rating", low: 13, citation: "VAT_IMAGING_THRESHOLD" },
+ ],
+ referenceId: "VAT_IMAGING_THRESHOLD",
+ guidanceCaveat:
+ "The consumer scale 'rating' (≈1–12 healthy) is a unitless scale, not the imaging cm² value (>100 cm² increased risk). High visceral fat is closely tied to heart disease and type 2 diabetes.",
+ },
+
+ // ── Sleep — adult 7–9 h; total sleep time beats the stage split. ──
+ SLEEP_DURATION: {
+ unit: "h",
+ bands: [
+ { label: "Adult 18–64", low: 7, high: 9, citation: "AASM_2015_SLEEP" },
+ { label: "Older adult 65+", low: 7, high: 8, citation: "AASM_2015_SLEEP" },
+ ],
+ referenceId: "AASM_2015_SLEEP",
+ guidanceCaveat:
+ "Both short and long habitual sleep carry risk. Regularity predicts health at least as strongly as duration, and a wearable's total sleep time is far more trustworthy than its deep/REM split.",
+ },
+};
+
+/** All reference-metric slugs the backbone covers. */
+export const REFERENCE_METRICS = Object.keys(
+ REFERENCE_RANGES,
+) as ReferenceMetric[];
+
+/** Type guard narrowing an arbitrary string to a covered reference metric. */
+export function isReferenceMetric(value: string): value is ReferenceMetric {
+ return Object.prototype.hasOwnProperty.call(REFERENCE_RANGES, value);
+}
+
+/** Resolve the reference range for a metric, or null when uncovered. */
+export function getReferenceRange(metric: string): ReferenceRange | null {
+ return isReferenceMetric(metric) ? REFERENCE_RANGES[metric] : null;
+}
+
+/**
+ * Localised "ESH 2023 (2023)"-style label for the headline band's source.
+ * The citation name is canonical and not translated (locale-agnostic).
+ */
+export function referenceLabel(metric: ReferenceMetric): string {
+ return citationLabel(REFERENCE_RANGES[metric].referenceId);
+}
+
+/**
+ * The four-state "where your value sits" phrasing contract (UI-1). A
+ * deterministic placement of a value against the metric's population
+ * bands, ALWAYS framed as general guidance. Consumers map each state to a
+ * cited i18n string; the state itself never asserts a diagnosis.
+ *
+ * - `within` — inside the normal/good band.
+ * - `slightly-outside`— in an adjacent watch band (e.g. high-normal,
+ * prediabetes, mild hypoxaemia).
+ * - `outside` — in a band the guideline flags for attention.
+ * - `insufficient` — no value, or the metric carries no population band
+ * (device-derived / baseline-only signals).
+ */
+export type ReferencePlacement =
+ | "within"
+ | "slightly-outside"
+ | "outside"
+ | "insufficient";
+
+/**
+ * Place a value against a metric's population bands. Returns
+ * `insufficient` when the value is absent or the metric has no fixed band
+ * (HRV and other device-derived signals defer wholly to the personal
+ * baseline). Pure + side-effect free; the user's own baseline still leads
+ * the read — this is only a coarse placement aid.
+ */
+export function classifyReference(
+ metric: ReferenceMetric,
+ value: number | null | undefined,
+): ReferencePlacement {
+ const range = REFERENCE_RANGES[metric];
+ if (value == null || !Number.isFinite(value) || range.bands.length === 0) {
+ return "insufficient";
+ }
+ // Find the first band whose half-open interval contains the value.
+ // Bands are authored in ascending order; the FIRST band is the
+ // normal/good placement, so its membership maps to `within`.
+ for (let i = 0; i < range.bands.length; i++) {
+ const band = range.bands[i];
+ const aboveLow = band.low == null || value >= band.low;
+ const belowHigh = band.high == null || value <= band.high;
+ if (aboveLow && belowHigh) {
+ if (i === 0) return "within";
+ // The band immediately adjacent to normal is the watch tier; any
+ // band beyond it is the attention tier.
+ return i === 1 ? "slightly-outside" : "outside";
+ }
+ }
+ // Value sits beyond every authored band (e.g. below an open-low normal,
+ // or above the top band's open-high). Treat an under-shoot of the first
+ // band's low as within when the first band is open-low; otherwise the
+ // value is outside the covered range → attention.
+ const first = range.bands[0];
+ if (first.low != null && value < first.low) return "outside";
+ return "outside";
+}
+
+/**
+ * Convenience: the citation entry backing a metric's headline band, for
+ * surfaces that want the url + caveat rather than the formatted label.
+ */
+export function referenceCitation(metric: ReferenceMetric) {
+ return CITATIONS[REFERENCE_RANGES[metric].referenceId];
+}
From 2c87a93827a404e2b130835a8c0d44a3bda93566 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 20:11:52 +0200
Subject: [PATCH 37/71] feat(glucose): add a diabetes opt-in preference
Introduce an explicit, user-declared "I have diabetes / clinician glucose
targets" preference (`User.hasDiabetes`, migration 0174, defaulted false) plus
its GET/PATCH endpoint at `/api/auth/me/diabetes`. The flag selects which
glucose target band a reading is judged against; it is never inferred from a
value and never asserts a diagnosis. Mirrors the existing per-user-scalar
preference pattern: 60/min rate limit, Zod validation, audit-logged,
field-by-field write. OpenAPI regenerated for the new route.
---
docs/api/openapi.yaml | 99 +++++++++++++++
.../0174_v1186_diabetes_opt_in/migration.sql | 15 +++
prisma/schema.prisma | 7 ++
src/app/api/auth/me/diabetes/route.ts | 118 ++++++++++++++++++
src/lib/openapi/routes/profile.ts | 55 ++++++++
5 files changed, 294 insertions(+)
create mode 100644 prisma/migrations/0174_v1186_diabetes_opt_in/migration.sql
create mode 100644 src/app/api/auth/me/diabetes/route.ts
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index b55ba0e06..3b6ddd195 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -1837,6 +1837,8 @@ paths:
minLength: 1
maxLength: 32
- type: "null"
+ symptomsPresent:
+ type: boolean
required:
- type
- value
@@ -3170,6 +3172,48 @@ paths:
"401": *a1
"422": *a3
"429": *a4
+ /api/auth/me/diabetes:
+ get:
+ tags:
+ - Auth
+ summary: Read per-user diabetes opt-in flag
+ description: Returns the user's per-account diabetes opt-in flag. Default `false`. When `true`, the glucose target
+ resolver applies the tighter ADA glycemic GOAL bands (fasting 80–130, postprandial < 180) instead of the general
+ non-diabetic bands. Powers the Settings "I have diabetes / clinician glucose targets" toggle. Never inferred
+ from a reading; asserts no diagnosis.
+ responses:
+ "200":
+ description: Resolved flag.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GetDiabetesResponse"
+ "401": *a1
+ "422": *a3
+ "429": *a4
+ patch:
+ tags:
+ - Auth
+ summary: Toggle per-user diabetes opt-in flag
+ description: Sets the per-account diabetes opt-in flag. Idempotent — the DB write fires even when the value matches so
+ the audit-log row mirrors the API call. Always returns the resolved next-state. `userId` is never accepted from
+ the body. Rate-limit 60/min per user.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DiabetesFlag"
+ responses:
+ "200":
+ description: Resolved next-state echoed back for optimistic-update consumers.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PatchDiabetesResponse"
+ "401": *a1
+ "422": *a3
+ "429": *a4
/api/auth/me/modules:
get:
tags:
@@ -7668,6 +7712,16 @@ components:
required:
- disableCoach
description: Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates.
+ DiabetesFlag:
+ type: object
+ properties:
+ hasDiabetes:
+ type: boolean
+ required:
+ - hasDiabetes
+ description: Per-account diabetes opt-in (v1.18.6). `true` selects the ADA glycemic GOAL bands (fasting 80–130,
+ postprandial < 180) for glucose targets instead of the general non-diabetic bands. A user-declared preference
+ only — never inferred from a value, never a diagnosis.
ModulePrefsPatchRequest:
type: object
properties:
@@ -13611,6 +13665,40 @@ components:
- data
- error
additionalProperties: false
+ GetDiabetesResponse:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/DiabetesFlagOutput"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ PatchDiabetesResponse:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/DiabetesFlagOutput"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
GetModulesResponse:
type: object
properties:
@@ -19607,6 +19695,17 @@ components:
- disableCoach
additionalProperties: false
description: Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates.
+ DiabetesFlagOutput:
+ type: object
+ properties:
+ hasDiabetes:
+ type: boolean
+ required:
+ - hasDiabetes
+ additionalProperties: false
+ description: Per-account diabetes opt-in (v1.18.6). `true` selects the ADA glycemic GOAL bands (fasting 80–130,
+ postprandial < 180) for glucose targets instead of the general non-diabetic bands. A user-declared preference
+ only — never inferred from a value, never a diagnosis.
InsightsLayoutBodyOutput:
type: object
properties:
diff --git a/prisma/migrations/0174_v1186_diabetes_opt_in/migration.sql b/prisma/migrations/0174_v1186_diabetes_opt_in/migration.sql
new file mode 100644
index 000000000..eaaa0fce8
--- /dev/null
+++ b/prisma/migrations/0174_v1186_diabetes_opt_in/migration.sql
@@ -0,0 +1,15 @@
+-- v1.18.6 — diabetes opt-in for clinician glucose targets.
+--
+-- One additive, defaulted column on `users` so a user can declare "I have
+-- diabetes / clinician glucose targets". When `true`, the glucose target
+-- resolver applies the tighter ADA glycemic GOAL bands (fasting 80–130,
+-- postprandial < 180) instead of the general non-diabetic normal bands.
+--
+-- This is NEVER auto-inferred from a reading and is NEVER a diagnosis — it is
+-- a user-declared preference that only selects which target band the user is
+-- judged against. The default `false` backfills every existing row onto the
+-- general bands (the current behaviour), so the migration is a no-op for
+-- anyone who never opts in.
+
+ALTER TABLE "users"
+ ADD COLUMN "has_diabetes" BOOLEAN NOT NULL DEFAULT false;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 948d0db58..f87097c6e 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -193,6 +193,13 @@ model User {
moodTagLayoutJson Json? @map("mood_tag_layout_json")
// Blood-glucose display unit preference. Canonical storage is mg/dL.
glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL"
+ // v1.18.6 — explicit "I have diabetes / clinician glucose targets" opt-in.
+ // When `true`, the glucose target resolver applies the tighter ADA glycemic
+ // GOAL bands (fasting 80–130, postprandial < 180) instead of the general
+ // non-diabetic normal bands. NEVER auto-inferred from values, NEVER a
+ // diagnosis — purely a user-declared preference that selects which target
+ // band the user is judged against. Default `false`.
+ hasDiabetes Boolean @default(false) @map("has_diabetes")
// v1.7.0 unit pref — global metric/imperial display preference.
// Canonical storage stays SI; this only selects the display-time
// transform branch (km/h vs mph, km vs mi). Null = default "metric".
diff --git a/src/app/api/auth/me/diabetes/route.ts b/src/app/api/auth/me/diabetes/route.ts
new file mode 100644
index 000000000..56d72da64
--- /dev/null
+++ b/src/app/api/auth/me/diabetes/route.ts
@@ -0,0 +1,118 @@
+/**
+ * v1.18.6 — per-user diabetes opt-in endpoint.
+ *
+ * GET /api/auth/me/diabetes — current flag.
+ * PATCH /api/auth/me/diabetes — body `{ hasDiabetes: boolean }`.
+ *
+ * The flag is an explicit, user-declared preference ("I have diabetes /
+ * clinician glucose targets"). When `true`, the glucose target resolver
+ * applies the tighter ADA glycemic GOAL bands (fasting 80–130, postprandial
+ * < 180) instead of the general non-diabetic normal bands. It is NEVER
+ * inferred from a reading and asserts NO diagnosis — it only selects which
+ * target band a glucose reading is judged against.
+ *
+ * Mirrors the `disable-coach` / `unit-preference` per-user-scalar pattern:
+ * 60/min rate limit, Zod safeParse → 422 via `returnAllZodIssues`, audit-log
+ * row, field-by-field write (no mass assignment). Idempotent — the endpoint
+ * always returns the resolved next state so the client can hard-set the
+ * optimistic update without an extra round-trip.
+ */
+import { z } from "zod";
+
+import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
+import {
+ apiError,
+ apiSuccess,
+ getClientIp,
+ returnAllZodIssues,
+} from "@/lib/api-response";
+import { annotate } from "@/lib/logging/context";
+import { auditLog } from "@/lib/auth/audit";
+import { prisma } from "@/lib/db";
+import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
+
+const patchBodySchema = z.object({
+ hasDiabetes: z.boolean(),
+});
+
+export const dynamic = "force-dynamic";
+
+const PATCH_RATE_LIMIT = 60;
+const PATCH_WINDOW_MS = 60_000;
+
+type DiabetesResponse = {
+ hasDiabetes: boolean;
+};
+
+export const GET = apiHandler(async () => {
+ const { user } = await requireAuth();
+ annotate({ action: { name: "auth.me.diabetes.get" } });
+
+ const row = await prisma.user.findUnique({
+ where: { id: user.id },
+ select: { hasDiabetes: true },
+ });
+ const payload: DiabetesResponse = {
+ hasDiabetes: row?.hasDiabetes ?? false,
+ };
+ return apiSuccess(payload);
+});
+
+export const PATCH = apiHandler(async (req: Request) => {
+ const { user } = await requireAuth();
+
+ const rl = await checkRateLimit(
+ `diabetes:patch:${user.id}`,
+ PATCH_RATE_LIMIT,
+ PATCH_WINDOW_MS,
+ );
+ if (!rl.allowed) {
+ const response = apiError("Too many requests", 429);
+ for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
+ response.headers.set(k, v);
+ }
+ return response;
+ }
+
+ let body: unknown;
+ try {
+ body = await req.json();
+ } catch {
+ throw new HttpError(422, "diabetes.body.invalid_json");
+ }
+
+ const parsed = patchBodySchema.safeParse(body);
+ if (!parsed.success) {
+ annotate({ action: { name: "auth.me.diabetes.patch.invalid_shape" } });
+ return returnAllZodIssues(parsed.error, 422);
+ }
+
+ const next = parsed.data.hasDiabetes;
+
+ const previous = await prisma.user.findUnique({
+ where: { id: user.id },
+ select: { hasDiabetes: true },
+ });
+
+ await prisma.user.update({
+ where: { id: user.id },
+ data: { hasDiabetes: next },
+ });
+
+ await auditLog("user.diabetes.update", {
+ userId: user.id,
+ ipAddress: getClientIp(req),
+ details: {
+ previous: previous?.hasDiabetes ?? false,
+ next,
+ },
+ });
+
+ annotate({
+ action: { name: "auth.me.diabetes.patch" },
+ meta: { hasDiabetes: next },
+ });
+
+ const payload: DiabetesResponse = { hasDiabetes: next };
+ return apiSuccess(payload);
+});
diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts
index 2a5654939..cd329e1ea 100644
--- a/src/lib/openapi/routes/profile.ts
+++ b/src/lib/openapi/routes/profile.ts
@@ -67,6 +67,20 @@ const disableCoachFlag = z
"Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates.",
});
+// v1.18.6 — explicit diabetes opt-in. Shared by GET (current) and PATCH
+// (request body + resolved response). `true` switches the glucose target
+// resolver to the tighter ADA glycemic GOAL bands (fasting 80–130,
+// postprandial < 180). Never inferred from a reading; asserts no diagnosis.
+const diabetesFlag = z
+ .object({
+ hasDiabetes: z.boolean(),
+ })
+ .meta({
+ id: "DiabetesFlag",
+ description:
+ "Per-account diabetes opt-in (v1.18.6). `true` selects the ADA glycemic GOAL bands (fasting 80–130, postprandial < 180) for glucose targets instead of the general non-diabetic bands. A user-declared preference only — never inferred from a value, never a diagnosis.",
+ });
+
// v1.16.11 — per-user notification preferences. The PATCH request body
// is the REAL runtime validator (`notificationPrefsSchema` — every
// category and field optional, deep-merged server-side); the response
@@ -415,6 +429,47 @@ export const profilePaths: NonNullable = {
},
},
},
+ "/api/auth/me/diabetes": {
+ get: {
+ tags: ["Auth"],
+ summary: "Read per-user diabetes opt-in flag",
+ description:
+ 'Returns the user\'s per-account diabetes opt-in flag. Default `false`. When `true`, the glucose target resolver applies the tighter ADA glycemic GOAL bands (fasting 80–130, postprandial < 180) instead of the general non-diabetic bands. Powers the Settings "I have diabetes / clinician glucose targets" toggle. Never inferred from a reading; asserts no diagnosis.',
+ responses: {
+ "200": {
+ description: "Resolved flag.",
+ content: {
+ "application/json": {
+ schema: dataEnvelope(diabetesFlag, "GetDiabetesResponse"),
+ },
+ },
+ },
+ ...stdResponses,
+ },
+ },
+ patch: {
+ tags: ["Auth"],
+ summary: "Toggle per-user diabetes opt-in flag",
+ description:
+ "Sets the per-account diabetes opt-in flag. Idempotent — the DB write fires even when the value matches so the audit-log row mirrors the API call. Always returns the resolved next-state. `userId` is never accepted from the body. Rate-limit 60/min per user.",
+ requestBody: {
+ required: true,
+ content: { "application/json": { schema: diabetesFlag } },
+ },
+ responses: {
+ "200": {
+ description:
+ "Resolved next-state echoed back for optimistic-update consumers.",
+ content: {
+ "application/json": {
+ schema: dataEnvelope(diabetesFlag, "PatchDiabetesResponse"),
+ },
+ },
+ },
+ ...stdResponses,
+ },
+ },
+ },
"/api/auth/me/modules": {
get: {
tags: ["Auth"],
From cf620de3153fae5472d94db7fd45721f565da842 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 20:11:59 +0200
Subject: [PATCH 38/71] feat(glucose): resolve tighter ADA goal targets when
diabetes is on
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add a diabetes-aware glucose target resolver: when the user has opted in, the
fasting/post-prandial targets switch to the tighter ADA glycemic GOAL bands
(fasting 80–130, post-meal under 180 mg/dL) instead of the general
non-diabetic normal bands. A user threshold override always wins over both, so
a clinician-pinned individualised goal is never replaced by the population
band. The targets surface now reads through the resolver and labels the source
accordingly. The general band display is untouched.
---
.../targets/__tests__/glucose-targets.test.ts | 98 ++++++++++++
src/lib/targets/build-response.ts | 46 ++++--
src/lib/targets/glucose-targets.ts | 147 ++++++++++++++++++
3 files changed, 275 insertions(+), 16 deletions(-)
create mode 100644 src/lib/targets/__tests__/glucose-targets.test.ts
create mode 100644 src/lib/targets/glucose-targets.ts
diff --git a/src/lib/targets/__tests__/glucose-targets.test.ts b/src/lib/targets/__tests__/glucose-targets.test.ts
new file mode 100644
index 000000000..1b2c10d1b
--- /dev/null
+++ b/src/lib/targets/__tests__/glucose-targets.test.ts
@@ -0,0 +1,98 @@
+/**
+ * v1.18.6 — diabetes-aware glucose target resolver.
+ *
+ * Pins: flag OFF → general non-diabetic band; flag ON → tighter ADA goal band;
+ * a user override always wins over BOTH; the flag is consumed verbatim (never
+ * derived from a value).
+ */
+import { describe, expect, it } from "vitest";
+import {
+ resolveGlucoseTarget,
+ DIABETIC_GOAL_BANDS,
+} from "../glucose-targets";
+import type { UserProfileForRange } from "@/lib/analytics/effective-range";
+
+const profile: UserProfileForRange = {
+ heightCm: 178,
+ dateOfBirth: "1985-01-01",
+ gender: "MALE",
+};
+
+describe("resolveGlucoseTarget", () => {
+ it("uses the general non-diabetic band when the flag is OFF", () => {
+ const r = resolveGlucoseTarget({
+ context: "FASTING",
+ hasDiabetes: false,
+ profile,
+ overrides: null,
+ });
+ expect(r.source).toBe("default");
+ expect(r.isOverride).toBe(false);
+ // General fasting normal band is 70–99 (non-diabetic).
+ expect(r.range?.greenMin).toBe(70);
+ expect(r.range?.greenMax).toBe(99);
+ });
+
+ it("uses the tighter ADA goal band when the flag is ON (fasting 80-130)", () => {
+ const r = resolveGlucoseTarget({
+ context: "FASTING",
+ hasDiabetes: true,
+ profile,
+ overrides: null,
+ });
+ expect(r.source).toBe("ADA goal (diabetes)");
+ expect(r.range?.greenMin).toBe(DIABETIC_GOAL_BANDS.FASTING.min);
+ expect(r.range?.greenMax).toBe(DIABETIC_GOAL_BANDS.FASTING.max);
+ expect(r.range?.greenMin).toBe(80);
+ expect(r.range?.greenMax).toBe(130);
+ });
+
+ it("uses the ADA postprandial goal band (< 180) when the flag is ON", () => {
+ const r = resolveGlucoseTarget({
+ context: "POSTPRANDIAL",
+ hasDiabetes: true,
+ profile,
+ overrides: null,
+ });
+ expect(r.range?.greenMin).toBe(80);
+ expect(r.range?.greenMax).toBe(180);
+ });
+
+ it("lets a user override win over the diabetic goal band", () => {
+ const r = resolveGlucoseTarget({
+ context: "FASTING",
+ hasDiabetes: true,
+ profile,
+ overrides: { BLOOD_GLUCOSE_FASTING: { min: 90, max: 120 } },
+ });
+ expect(r.source).toBe("custom");
+ expect(r.isOverride).toBe(true);
+ expect(r.range?.greenMin).toBe(90);
+ expect(r.range?.greenMax).toBe(120);
+ });
+
+ it("lets a user override win over the general band (flag OFF)", () => {
+ const r = resolveGlucoseTarget({
+ context: "FASTING",
+ hasDiabetes: false,
+ profile,
+ overrides: { BLOOD_GLUCOSE_FASTING: { min: 75, max: 105 } },
+ });
+ expect(r.source).toBe("custom");
+ expect(r.range?.greenMin).toBe(75);
+ expect(r.range?.greenMax).toBe(105);
+ });
+
+ it("resolves the diabetic goal band identically for every context", () => {
+ for (const ctx of ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"] as const) {
+ const r = resolveGlucoseTarget({
+ context: ctx,
+ hasDiabetes: true,
+ profile,
+ overrides: null,
+ });
+ expect(r.range?.greenMin).toBe(DIABETIC_GOAL_BANDS[ctx].min);
+ expect(r.range?.greenMax).toBe(DIABETIC_GOAL_BANDS[ctx].max);
+ }
+ });
+});
diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts
index 7bae2df9d..1c2489df0 100644
--- a/src/lib/targets/build-response.ts
+++ b/src/lib/targets/build-response.ts
@@ -46,12 +46,10 @@ import type {
MeasurementType,
GlucoseContext,
} from "@/generated/prisma/client";
-import {
- getEffectiveRange,
- type ThresholdOverridesJson,
-} from "@/lib/analytics/effective-range";
+import type { ThresholdOverridesJson } from "@/lib/analytics/effective-range";
import { getBodyFatTargetRange } from "@/lib/analytics/value-bands";
-import { thresholdMetricForContext, resolveGlucoseUnit } from "@/lib/glucose";
+import { resolveGlucoseUnit } from "@/lib/glucose";
+import { resolveGlucoseTarget } from "@/lib/targets/glucose-targets";
import {
ensureUserMoodRollupsFresh,
readMoodDayRollups,
@@ -219,6 +217,9 @@ export async function buildTargetsResponse(user: AuthedUser) {
dateOfBirth: true,
gender: true,
glucoseUnit: true,
+ // v1.18.6 — diabetes opt-in selects the tighter ADA glycemic goal
+ // band for glucose targets (resolved below via resolveGlucoseTarget).
+ hasDiabetes: true,
thresholdsJson: true,
// v1.11.5 — needed to collapse a dual-source sleep night to one
// canonical source before reconstructing per-night asleep totals.
@@ -1456,20 +1457,28 @@ export async function buildTargetsResponse(user: AuthedUser) {
) / 10
: null;
- const metric = thresholdMetricForContext(ctx);
- const eff = getEffectiveRange(metric, profileForRange, overrides);
- const range = eff.range
- ? { min: eff.range.greenMin, max: eff.range.greenMax }
+ // v1.18.6 — diabetes-aware target band. When the user has declared
+ // diabetes (and has NOT pinned a custom override) the tighter ADA glycemic
+ // GOAL band wins; otherwise the general non-diabetic band (or the override)
+ // is used exactly as before. The flag is never inferred from a value.
+ const resolved = resolveGlucoseTarget({
+ context: ctx,
+ hasDiabetes: dbUser?.hasDiabetes ?? false,
+ profile: profileForRange,
+ overrides,
+ });
+ const effRange = resolved.range;
+ const range = effRange
+ ? { min: effRange.greenMin, max: effRange.greenMax }
: null;
let classification: { category: string; color: string } | null = null;
- if (range) {
+ if (range && effRange) {
if (latest >= range.min && latest <= range.max) {
classification = { category: "Optimal", color: "#50fa7b" };
} else if (
- eff.range &&
- latest >= eff.range.orangeMin &&
- latest <= eff.range.orangeMax
+ latest >= effRange.orangeMin &&
+ latest <= effRange.orangeMax
) {
classification = { category: "Elevated", color: "#f1fa8c" };
} else {
@@ -1481,8 +1490,8 @@ export async function buildTargetsResponse(user: AuthedUser) {
recent.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
makeRangeClassifier(
range,
- eff.range
- ? { orangeMin: eff.range.orangeMin, orangeMax: eff.range.orangeMax }
+ effRange
+ ? { orangeMin: effRange.orangeMin, orangeMax: effRange.orangeMax }
: undefined,
),
);
@@ -1495,7 +1504,12 @@ export async function buildTargetsResponse(user: AuthedUser) {
unit: glucoseUnit,
range,
classification,
- source: eff.isOverride ? "Custom" : "ADA 2024 / DDG",
+ source:
+ resolved.source === "custom"
+ ? "Custom"
+ : resolved.source === "ADA goal (diabetes)"
+ ? "ADA goal (diabetes)"
+ : "ADA 2024 / DDG",
...consistency,
});
}
diff --git a/src/lib/targets/glucose-targets.ts b/src/lib/targets/glucose-targets.ts
new file mode 100644
index 000000000..a52b6dfaa
--- /dev/null
+++ b/src/lib/targets/glucose-targets.ts
@@ -0,0 +1,147 @@
+/**
+ * Diabetes-aware blood-glucose TARGET resolver (v1.18.6).
+ *
+ * One question: "what glucose band should THIS user be judged against on this
+ * context, right now?" The answer depends on a single explicit, user-declared
+ * preference — `User.hasDiabetes` — and NEVER on any reading. The flag is set
+ * through Settings (`PATCH /api/auth/me/diabetes`); it is never inferred from
+ * a value and never asserts a diagnosis.
+ *
+ * - Flag OFF (default): the general non-diabetic normal bands — the existing
+ * behaviour, resolved by the shared reference-range resolver
+ * (`getEffectiveRange`, which owns the band display) so a user-override
+ * still wins and nothing about the calm-state UI changes.
+ * - Flag ON: the tighter ADA glycemic GOAL bands for people living with
+ * diabetes — fasting / pre-prandial 80–130 mg/dL, peak post-prandial
+ * < 180 mg/dL (ADA Standards of Care §6, stable 2024–2026). A user
+ * override still wins (a clinician may pin an individualised goal), but
+ * absent an override the diabetic goal band — not the normal band — is the
+ * target.
+ *
+ * The diabetic goal targets are GOALS, not screening thresholds: a fasting 80
+ * is "in goal" for someone managing diabetes, where the non-diabetic 70–99
+ * normal band would call the same reading low-normal. This is exactly why we
+ * keep the two resolvers distinct rather than widening the general band.
+ *
+ * Pure given its inputs. The route resolves the flag + overrides and passes
+ * them in. mg/dL throughout (HealthLog canonical store unit); display-time
+ * conversion to mmol/L is the caller's job (`src/lib/glucose.ts`).
+ *
+ * Citation: American Diabetes Association, Standards of Care in Diabetes
+ * §6 "Glycemic Goals and Hypoglycemia", Diabetes Care 2024 47(Suppl. 1):S111.
+ * Pre-prandial plasma glucose goal 80–130 mg/dL; peak post-prandial < 180
+ * mg/dL. https://diabetesjournals.org/care/article/47/Supplement_1/S111/153957
+ */
+
+import {
+ getEffectiveRange,
+ type ThresholdMetric,
+ type ThresholdOverridesJson,
+ type UserProfileForRange,
+} from "@/lib/analytics/effective-range";
+import type { TrafficRange } from "@/lib/analytics/value-bands";
+import { thresholdMetricForContext } from "@/lib/glucose";
+
+export type GlucoseContextKey =
+ | "FASTING"
+ | "POSTPRANDIAL"
+ | "RANDOM"
+ | "BEDTIME";
+
+/**
+ * ADA glycemic GOAL bands for people living with diabetes (mg/dL).
+ *
+ * FASTING / pre-prandial: 80–130 (ADA pre-prandial plasma glucose goal).
+ * POSTPRANDIAL: floor 80 (avoid hypoglycemia), ceiling 180 (ADA peak
+ * post-prandial goal `< 180`; the inclusive 180 is the band edge).
+ * RANDOM: a random spot reading is not a defined ADA goal context; the
+ * post-prandial goal is the most clinically defensible comparator (a random
+ * reading is most often post-meal). Use the same 80–180 band.
+ * BEDTIME: ADA publishes no separate bedtime goal for adults; the ISPAD
+ * pediatric bedtime band (90–150) is the closest comparator and the slightly
+ * higher floor guards against nocturnal hypoglycemia. Kept identical to the
+ * non-diabetic bedtime band on purpose — the diabetic delta lives in the
+ * fasting/post-prandial goals, where ADA is explicit.
+ */
+const DIABETIC_GOAL_BANDS: Record<
+ GlucoseContextKey,
+ { min: number; max: number }
+> = {
+ FASTING: { min: 80, max: 130 },
+ POSTPRANDIAL: { min: 80, max: 180 },
+ RANDOM: { min: 80, max: 180 },
+ BEDTIME: { min: 90, max: 150 },
+};
+
+/** Source label surfaced on the resolved range for the targets surface. */
+export type GlucoseTargetSource = "ADA goal (diabetes)" | "default" | "custom";
+
+export interface ResolvedGlucoseTarget {
+ /** The green band the reading is judged against (mg/dL). */
+ range: TrafficRange | null;
+ /** True when a user threshold override is in play (override always wins). */
+ isOverride: boolean;
+ /**
+ * Which band family produced the range: the diabetic ADA goal, the general
+ * default, or a user custom override. Drives the targets-card source label.
+ */
+ source: GlucoseTargetSource;
+}
+
+/**
+ * Build the diabetic-goal `TrafficRange`. The orange wings mirror the general
+ * resolver's glucose convention (floor − 10 below, a modest ceiling above) so
+ * a reading just outside goal reads "elevated", not "high".
+ */
+function diabeticGoalRange(ctx: GlucoseContextKey): TrafficRange {
+ const band = DIABETIC_GOAL_BANDS[ctx];
+ return {
+ greenMin: band.min,
+ greenMax: band.max,
+ orangeMin: band.min - 10,
+ // Above the goal ceiling stays "elevated" up to a modest margin before
+ // the red "high" band; matches the general resolver's wing width posture.
+ orangeMax: band.max + 30,
+ };
+}
+
+/**
+ * Resolve the glucose target band for one context.
+ *
+ * `hasDiabetes` is the user's explicit, declared preference. When `true` AND
+ * the user has NOT overridden the metric, the ADA diabetic goal band wins.
+ * Otherwise we defer entirely to the shared reference-range resolver so the
+ * general non-diabetic band (and any user override) behaves exactly as before.
+ *
+ * The flag is NEVER derived from a reading and asserts no diagnosis — it only
+ * selects the band the user is measured against.
+ */
+export function resolveGlucoseTarget(input: {
+ context: GlucoseContextKey;
+ hasDiabetes: boolean;
+ profile: UserProfileForRange;
+ overrides: ThresholdOverridesJson | null | undefined;
+}): ResolvedGlucoseTarget {
+ const { context, hasDiabetes, profile, overrides } = input;
+ const metric = thresholdMetricForContext(context) as ThresholdMetric;
+ const eff = getEffectiveRange(metric, profile, overrides);
+
+ // A user override always wins — a clinician-pinned individualised goal must
+ // not be silently replaced by the population ADA goal band.
+ if (eff.isOverride) {
+ return { range: eff.range, isOverride: true, source: "custom" };
+ }
+
+ if (hasDiabetes) {
+ return {
+ range: diabeticGoalRange(context),
+ isOverride: false,
+ source: "ADA goal (diabetes)",
+ };
+ }
+
+ return { range: eff.range, isOverride: false, source: "default" };
+}
+
+/** The ADA diabetic goal bands, exported for tests + documentation. */
+export { DIABETIC_GOAL_BANDS };
From f20be2918958410d97f206c782f1ee055a683d27 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 20:12:11 +0200
Subject: [PATCH 39/71] feat(illness): escalate confirmed clinical safety
floors
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add absolute, guideline-backed safety floors that escalate a freshly logged
reading — but only after a second, confirming reading inside a one-hour
re-test window, so a single oscillometric spike or a one-off finger-stick can
never raise a false alarm:
- blood pressure 180/120 or higher (re-check, contact your doctor; symptom-
coupled lifts to emergency copy) and a symmetric low-systolic-under-90
cautionary tier;
- glucose under 70 (low) and under 54 (very low), and a sustained very-high
250-or-higher tier. The 200 DKA criterion deliberately never shows an
all-clear and never urgently escalates on its own, since a value alone
cannot rule euglycemic DKA in or out.
The escalation reuses the urgent SYSTEM_ALERT path with a 24h per-reason
dedup, never names a condition, and gates glucose behind the glucose module.
An optional transient `symptomsPresent` flag on the measurement write lifts a
confirmed breach from the contact-your-doctor copy to the emergency copy. The
measurement POST runs the check fire-and-forget so a notification failure
never fails the user's write.
---
src/app/api/measurements/route.ts | 51 +++-
.../__tests__/safety-floor-notify.test.ts | 107 ++++++++
.../illness/__tests__/safety-floors.test.ts | 227 ++++++++++++++++
src/lib/illness/safety-floor-check.ts | 173 +++++++++++++
src/lib/illness/safety-floor-notify.ts | 123 +++++++++
src/lib/illness/safety-floors.ts | 242 ++++++++++++++++++
src/lib/validations/measurement.ts | 6 +
7 files changed, 927 insertions(+), 2 deletions(-)
create mode 100644 src/lib/illness/__tests__/safety-floor-notify.test.ts
create mode 100644 src/lib/illness/__tests__/safety-floors.test.ts
create mode 100644 src/lib/illness/safety-floor-check.ts
create mode 100644 src/lib/illness/safety-floor-notify.ts
create mode 100644 src/lib/illness/safety-floors.ts
diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts
index 77c23833a..0aac4a53c 100644
--- a/src/app/api/measurements/route.ts
+++ b/src/app/api/measurements/route.ts
@@ -30,6 +30,7 @@ import { withIdempotency } from "@/lib/idempotency";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
+import { runSafetyFloorCheck } from "@/lib/illness/safety-floor-check";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
@@ -776,6 +777,25 @@ async function postMeasurement(request: NextRequest) {
// reminders against the just-landed readings now. Fire-and-forget.
void enqueueReminderSatisfy(user.id).catch(() => {});
+ // v1.18.6 — absolute clinical safety-floor check on the just-written
+ // readings (confirm-gated, module-gated, never diagnoses). The combined
+ // BP form lands both arms here, so the BP floor sees a whole reading.
+ // The transient per-entry `symptomsPresent` flag (ORed across the batch)
+ // lifts a confirmed breach to the symptom-coupled emergency copy.
+ // Fire-and-forget — a notification failure never fails the write.
+ void runSafetyFloorCheck({
+ userId: user.id,
+ written: results.map((r) => ({
+ type: r.type,
+ value: r.value,
+ measuredAt: r.measuredAt,
+ glucoseContext: r.glucoseContext,
+ })),
+ symptomsPresent: parsed.data.measurements.some(
+ (m) => m.symptomsPresent === true,
+ ),
+ }).catch(() => {});
+
// v1.5.0 — refresh the persistent rollup table for every distinct
// (type, day) the batch touched so the next analytics / coach read
// hits the cache rather than falling through to live aggregation.
@@ -836,8 +856,16 @@ async function postMeasurement(request: NextRequest) {
return returnAllZodIssues(parsed.error, 422);
}
- const { type, value, measuredAt, notes, source, glucoseContext, deviceType } =
- parsed.data;
+ const {
+ type,
+ value,
+ measuredAt,
+ notes,
+ source,
+ glucoseContext,
+ deviceType,
+ symptomsPresent,
+ } = parsed.data;
// Handle unique constraint violation
let measurement;
@@ -893,6 +921,25 @@ async function postMeasurement(request: NextRequest) {
// against the just-landed reading now. Fire-and-forget.
void enqueueReminderSatisfy(user.id).catch(() => {});
+ // v1.18.6 — absolute clinical safety-floor check (confirm-gated,
+ // module-gated, never diagnoses). A single-entry POST carries only one arm
+ // of a BP reading, so the BP floor only fires through the combined-form /
+ // batch path; a lone glucose reading is evaluated here. The transient
+ // `symptomsPresent` flag lifts a confirmed breach to the emergency copy.
+ // Fire-and-forget — a notification failure never fails the write.
+ void runSafetyFloorCheck({
+ userId: user.id,
+ written: [
+ {
+ type: measurement.type,
+ value: measurement.value,
+ measuredAt: measurement.measuredAt,
+ glucoseContext: measurement.glucoseContext,
+ },
+ ],
+ symptomsPresent,
+ }).catch(() => {});
+
// v1.5.0 — refresh the persistent rollup row for the affected
// (type, day) tuple. Runs inline so the next read of the
// comprehensive / analytics surface is correct without waiting on
diff --git a/src/lib/illness/__tests__/safety-floor-notify.test.ts b/src/lib/illness/__tests__/safety-floor-notify.test.ts
new file mode 100644
index 000000000..b16aaa123
--- /dev/null
+++ b/src/lib/illness/__tests__/safety-floor-notify.test.ts
@@ -0,0 +1,107 @@
+/**
+ * v1.18.6 — safety-floor escalation push.
+ *
+ * Pins: null decision = no-op; a confirmed breach dispatches an URGENT
+ * SYSTEM_ALERT; asymptomatic → Doctor copy, symptom-coupled → Emergency copy;
+ * a recent ledger row de-dupes by reason; failures never throw; the ledger
+ * anchor is stamped before dispatch.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+const findFirstMock = vi.fn();
+const createMock = vi.fn();
+const dispatchMock = vi.fn();
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ pushAttempt: {
+ findFirst: (...a: unknown[]) => findFirstMock(...a),
+ create: (...a: unknown[]) => createMock(...a),
+ },
+ },
+}));
+
+vi.mock("@/lib/logging/context", () => ({
+ getEvent: () => ({ addMeta: vi.fn(), addWarning: vi.fn() }),
+}));
+
+vi.mock("@/lib/notifications/dispatch-localised", () => ({
+ dispatchLocalisedNotification: (...a: unknown[]) => dispatchMock(...a),
+}));
+
+import { notifySafetyFloor } from "../safety-floor-notify";
+import type { SafetyFloorDecision } from "../safety-floors";
+
+const bpHigh: SafetyFloorDecision = {
+ kind: "BLOOD_PRESSURE",
+ reason: "bp_hypertensive",
+ tier: "severe",
+ symptomCoupled: false,
+ value: 188,
+ diastolic: 122,
+};
+const glucoseLowSymptomatic: SafetyFloorDecision = {
+ kind: "GLUCOSE",
+ reason: "glucose_hypo_severe",
+ tier: "severe",
+ symptomCoupled: true,
+ value: 48,
+ diastolic: null,
+};
+
+beforeEach(() => {
+ findFirstMock.mockReset().mockResolvedValue(null);
+ createMock.mockReset().mockResolvedValue({});
+ dispatchMock.mockReset().mockResolvedValue(undefined);
+});
+
+afterEach(() => vi.clearAllMocks());
+
+describe("notifySafetyFloor", () => {
+ it("no-op when the decision is null", async () => {
+ await notifySafetyFloor({ userId: "u1", decision: null });
+ expect(dispatchMock).not.toHaveBeenCalled();
+ expect(findFirstMock).not.toHaveBeenCalled();
+ });
+
+ it("dispatches an URGENT SYSTEM_ALERT with the asymptomatic Doctor copy", async () => {
+ await notifySafetyFloor({ userId: "u1", decision: bpHigh });
+ expect(dispatchMock).toHaveBeenCalledTimes(1);
+ const opts = dispatchMock.mock.calls[0][0];
+ expect(opts.urgent).toBe(true);
+ expect(opts.eventType).toBe("SYSTEM_ALERT");
+ expect(opts.userId).toBe("u1");
+ expect(opts.titleKey).toBe("safety.floor.bpHighTitle");
+ expect(opts.messageKey).toBe("safety.floor.bpHighDoctor");
+ expect(opts.params).toEqual({ systolic: 188, diastolic: 122 });
+ });
+
+ it("uses the Emergency copy variant when symptom-coupled", async () => {
+ await notifySafetyFloor({ userId: "u1", decision: glucoseLowSymptomatic });
+ const opts = dispatchMock.mock.calls[0][0];
+ expect(opts.messageKey).toBe("safety.floor.glucoseLowSevereEmergency");
+ expect(opts.titleKey).toBe("safety.floor.glucoseLowSevereTitle");
+ expect(opts.params).toEqual({ value: 48 });
+ });
+
+ it("de-dupes by reason when a recent ledger row exists", async () => {
+ findFirstMock.mockResolvedValueOnce({ id: "prior" });
+ await notifySafetyFloor({ userId: "u1", decision: bpHigh });
+ expect(dispatchMock).not.toHaveBeenCalled();
+ expect(createMock).not.toHaveBeenCalled();
+ });
+
+ it("stamps the ledger anchor (keyed by reason) before dispatching", async () => {
+ await notifySafetyFloor({ userId: "u9", decision: bpHigh });
+ expect(createMock).toHaveBeenCalledTimes(1);
+ expect(createMock.mock.calls[0][0].data.reason).toContain("bp_hypertensive");
+ expect(createMock.mock.calls[0][0].data.eventType).toBe("SYSTEM_ALERT");
+ });
+
+ it("never throws when dispatch fails", async () => {
+ dispatchMock.mockRejectedValueOnce(new Error("boom"));
+ await expect(
+ notifySafetyFloor({ userId: "u1", decision: bpHigh }),
+ ).resolves.toBeUndefined();
+ });
+});
diff --git a/src/lib/illness/__tests__/safety-floors.test.ts b/src/lib/illness/__tests__/safety-floors.test.ts
new file mode 100644
index 000000000..4af788946
--- /dev/null
+++ b/src/lib/illness/__tests__/safety-floors.test.ts
@@ -0,0 +1,227 @@
+/**
+ * v1.18.6 — absolute clinical safety-floor engine.
+ *
+ * Pins each threshold + the confirm-before-alarm gate. The engine must NEVER
+ * escalate on a single breach reading — a prior same-floor reading inside the
+ * confirm window is required. Severe tiers keep the confirm gate; symptom
+ * coupling only lifts the copy tier, never the gate.
+ */
+import { describe, expect, it } from "vitest";
+import {
+ evaluateBloodPressure,
+ evaluateGlucose,
+ CONFIRM_WINDOW_MS,
+ BP_SYS_HYPERTENSIVE,
+ BP_DIA_HYPERTENSIVE,
+ BP_SYS_HYPOTENSIVE,
+ GLUCOSE_HYPO,
+ GLUCOSE_HYPO_SEVERE,
+ GLUCOSE_HYPER,
+ type BpSample,
+ type GlucoseSample,
+} from "../safety-floors";
+
+const T0 = new Date("2026-06-18T09:00:00Z");
+/** A confirming prior reading 10 minutes before the candidate (in window). */
+const T_PRIOR = new Date(T0.getTime() - 10 * 60 * 1000);
+/** A prior reading just outside the confirm window. */
+const T_STALE = new Date(T0.getTime() - CONFIRM_WINDOW_MS - 60 * 1000);
+
+function bp(measuredAt: Date, systolic: number, diastolic: number): BpSample {
+ return { measuredAt, systolic, diastolic };
+}
+function gl(measuredAt: Date, mgdl: number): GlucoseSample {
+ return { measuredAt, mgdl };
+}
+
+describe("safety-floors — blood pressure", () => {
+ it("does not escalate on a single hypertensive reading (no confirm)", () => {
+ const d = evaluateBloodPressure({
+ candidate: bp(T0, 185, 110),
+ recent: [],
+ symptomCoupled: false,
+ });
+ expect(d).toBeNull();
+ });
+
+ it("escalates a confirmed hypertensive breach (>=180 systolic) at severe tier", () => {
+ const d = evaluateBloodPressure({
+ candidate: bp(T0, BP_SYS_HYPERTENSIVE, 100),
+ recent: [bp(T_PRIOR, 182, 98)],
+ symptomCoupled: false,
+ });
+ expect(d).not.toBeNull();
+ expect(d!.reason).toBe("bp_hypertensive");
+ expect(d!.tier).toBe("severe");
+ expect(d!.symptomCoupled).toBe(false);
+ expect(d!.value).toBe(BP_SYS_HYPERTENSIVE);
+ expect(d!.diastolic).toBe(100);
+ });
+
+ it("escalates when only the diastolic crosses the floor (>=120)", () => {
+ const d = evaluateBloodPressure({
+ candidate: bp(T0, 150, BP_DIA_HYPERTENSIVE),
+ recent: [bp(T_PRIOR, 150, 122)],
+ symptomCoupled: false,
+ });
+ expect(d?.reason).toBe("bp_hypertensive");
+ });
+
+ it("does not confirm with a reading outside the window", () => {
+ const d = evaluateBloodPressure({
+ candidate: bp(T0, 190, 115),
+ recent: [bp(T_STALE, 190, 115)],
+ symptomCoupled: false,
+ });
+ expect(d).toBeNull();
+ });
+
+ it("does not confirm with a prior reading of a DIFFERENT floor family", () => {
+ // candidate hypertensive, prior hypotensive — not the same floor.
+ const d = evaluateBloodPressure({
+ candidate: bp(T0, 185, 110),
+ recent: [bp(T_PRIOR, 85, 55)],
+ symptomCoupled: false,
+ });
+ expect(d).toBeNull();
+ });
+
+ it("treats low BP (<90 systolic) as caution when asymptomatic", () => {
+ const d = evaluateBloodPressure({
+ candidate: bp(T0, BP_SYS_HYPOTENSIVE - 1, 55),
+ recent: [bp(T_PRIOR, 86, 54)],
+ symptomCoupled: false,
+ });
+ expect(d?.reason).toBe("bp_hypotensive");
+ expect(d?.tier).toBe("caution");
+ });
+
+ it("lifts low BP to severe when symptom-coupled", () => {
+ const d = evaluateBloodPressure({
+ candidate: bp(T0, 85, 55),
+ recent: [bp(T_PRIOR, 86, 54)],
+ symptomCoupled: true,
+ });
+ expect(d?.reason).toBe("bp_hypotensive");
+ expect(d?.tier).toBe("severe");
+ expect(d?.symptomCoupled).toBe(true);
+ });
+
+ it("does not escalate a normal reading", () => {
+ const d = evaluateBloodPressure({
+ candidate: bp(T0, 120, 78),
+ recent: [bp(T_PRIOR, 122, 80)],
+ symptomCoupled: false,
+ });
+ expect(d).toBeNull();
+ });
+
+ it("ignores a future-dated prior reading (after the candidate)", () => {
+ const future = new Date(T0.getTime() + 5 * 60 * 1000);
+ const d = evaluateBloodPressure({
+ candidate: bp(T0, 185, 110),
+ recent: [bp(future, 185, 110)],
+ symptomCoupled: false,
+ });
+ expect(d).toBeNull();
+ });
+});
+
+describe("safety-floors — glucose", () => {
+ it("does not escalate a single low reading (no confirm)", () => {
+ const d = evaluateGlucose({
+ candidate: gl(T0, 65),
+ recent: [],
+ symptomCoupled: false,
+ });
+ expect(d).toBeNull();
+ });
+
+ it("escalates a confirmed hypo (<70) at caution tier", () => {
+ const d = evaluateGlucose({
+ candidate: gl(T0, GLUCOSE_HYPO - 1),
+ recent: [gl(T_PRIOR, 66)],
+ symptomCoupled: false,
+ });
+ expect(d?.reason).toBe("glucose_hypo");
+ expect(d?.tier).toBe("caution");
+ expect(d?.value).toBe(GLUCOSE_HYPO - 1);
+ });
+
+ it("escalates a confirmed severe-hypo (<54) at severe tier", () => {
+ const d = evaluateGlucose({
+ candidate: gl(T0, GLUCOSE_HYPO_SEVERE - 4),
+ recent: [gl(T_PRIOR, 52)],
+ symptomCoupled: false,
+ });
+ expect(d?.reason).toBe("glucose_hypo_severe");
+ expect(d?.tier).toBe("severe");
+ });
+
+ it("confirms a severe-hypo candidate with any low (<70) re-test (same floor family)", () => {
+ const d = evaluateGlucose({
+ candidate: gl(T0, 50),
+ recent: [gl(T_PRIOR, 68)], // 68 is hypo but not severe — still confirms LOW family
+ symptomCoupled: false,
+ });
+ expect(d?.reason).toBe("glucose_hypo_severe");
+ expect(d?.tier).toBe("severe");
+ });
+
+ it("escalates a confirmed hyper (>=250) at severe tier", () => {
+ const d = evaluateGlucose({
+ candidate: gl(T0, GLUCOSE_HYPER),
+ recent: [gl(T_PRIOR, 270)],
+ symptomCoupled: false,
+ });
+ expect(d?.reason).toBe("glucose_hyper");
+ expect(d?.tier).toBe("severe");
+ });
+
+ it("does NOT escalate the 200 DKA criterion (euglycemic-DKA conservatism)", () => {
+ // ADA-2026 lowered the DKA criterion to >=200, but a value alone cannot
+ // rule DKA in or out, so we never URGENTLY escalate at 200 — only at 250.
+ const d = evaluateGlucose({
+ candidate: gl(T0, 210),
+ recent: [gl(T_PRIOR, 220)],
+ symptomCoupled: false,
+ });
+ expect(d).toBeNull();
+ });
+
+ it("does not confirm a low candidate with a high re-test (different family)", () => {
+ const d = evaluateGlucose({
+ candidate: gl(T0, 60),
+ recent: [gl(T_PRIOR, 260)],
+ symptomCoupled: false,
+ });
+ expect(d).toBeNull();
+ });
+
+ it("does not confirm outside the window", () => {
+ const d = evaluateGlucose({
+ candidate: gl(T0, 50),
+ recent: [gl(T_STALE, 50)],
+ symptomCoupled: false,
+ });
+ expect(d).toBeNull();
+ });
+
+ it("carries the symptom flag through to the decision", () => {
+ const d = evaluateGlucose({
+ candidate: gl(T0, 300),
+ recent: [gl(T_PRIOR, 280)],
+ symptomCoupled: true,
+ });
+ expect(d?.symptomCoupled).toBe(true);
+ });
+
+ it("does not escalate an in-range reading", () => {
+ const d = evaluateGlucose({
+ candidate: gl(T0, 110),
+ recent: [gl(T_PRIOR, 105)],
+ symptomCoupled: false,
+ });
+ expect(d).toBeNull();
+ });
+});
diff --git a/src/lib/illness/safety-floor-check.ts b/src/lib/illness/safety-floor-check.ts
new file mode 100644
index 000000000..1b25e1520
--- /dev/null
+++ b/src/lib/illness/safety-floor-check.ts
@@ -0,0 +1,173 @@
+/**
+ * Safety-floor write-hook orchestrator (v1.18.6).
+ *
+ * Runs AFTER a measurement write lands. Resolves the recent same-kind readings
+ * the confirm gate needs, runs the absolute-floor engine (`safety-floors.ts`),
+ * and dispatches an urgent escalation (`safety-floor-notify.ts`) when — and
+ * only when — a fresh reading breaches a clinical floor AND a prior reading
+ * inside the confirm window held the same floor.
+ *
+ * Module-gated: glucose checks only run when the `glucose` module is enabled;
+ * BP is a CORE domain (always on) but the BP check is still cheap and only
+ * fires on an extreme reading. Owner-scoped, fire-and-forget, never throws —
+ * a notification failure must never fail the user's measurement write.
+ *
+ * The caller (the measurement POST route) passes the just-written rows plus
+ * the transient `symptomsPresent` flag; this helper does the bounded reads.
+ */
+import { prisma } from "@/lib/db";
+import { getEvent } from "@/lib/logging/context";
+import { isModuleEnabled } from "@/lib/modules/gate";
+import {
+ evaluateBloodPressure,
+ evaluateGlucose,
+ CONFIRM_WINDOW_MS,
+ type BpSample,
+ type GlucoseSample,
+} from "@/lib/illness/safety-floors";
+import { notifySafetyFloor } from "@/lib/illness/safety-floor-notify";
+
+/** A row the write hook just persisted (the subset the check needs). */
+export interface WrittenReading {
+ type: string;
+ value: number;
+ measuredAt: Date;
+ glucoseContext?: string | null;
+}
+
+/**
+ * Evaluate the just-written readings against the absolute safety floors and
+ * dispatch any confirmed escalation. Best-effort.
+ *
+ * `symptomsPresent` is the transient per-write flag — true lifts a confirmed
+ * breach to the symptom-coupled emergency copy. Defaults to false.
+ */
+export async function runSafetyFloorCheck(input: {
+ userId: string;
+ written: WrittenReading[];
+ symptomsPresent?: boolean;
+}): Promise {
+ const { userId, written } = input;
+ const symptomCoupled = input.symptomsPresent === true;
+
+ try {
+ await Promise.all([
+ checkBloodPressure(userId, written, symptomCoupled),
+ checkGlucose(userId, written, symptomCoupled),
+ ]);
+ } catch (err) {
+ getEvent()?.addWarning(
+ `safety-floor check failed: ${
+ err instanceof Error ? err.message : String(err)
+ }`,
+ );
+ }
+}
+
+/* ── blood pressure ───────────────────────────────────────────────────── */
+
+async function checkBloodPressure(
+ userId: string,
+ written: WrittenReading[],
+ symptomCoupled: boolean,
+): Promise {
+ // BP is stored as two rows (SYS + DIA). We only evaluate when BOTH arms of
+ // the candidate reading are present in this write (the normal combined-form
+ // and iOS batch path) so we never reason about half a reading.
+ const sys = written.find((r) => r.type === "BLOOD_PRESSURE_SYS");
+ const dia = written.find((r) => r.type === "BLOOD_PRESSURE_DIA");
+ if (!sys || !dia) return;
+
+ const candidate: BpSample = {
+ measuredAt: sys.measuredAt,
+ systolic: sys.value,
+ diastolic: dia.value,
+ };
+
+ // Pull the recent SYS + DIA rows inside the confirm window and pair them by
+ // timestamp so the engine sees prior whole readings, not loose arms. The
+ // candidate's own rows are excluded by the strict `lt: measuredAt` bound.
+ const windowStart = new Date(
+ candidate.measuredAt.getTime() - CONFIRM_WINDOW_MS,
+ );
+ const rows = await prisma.measurement.findMany({
+ where: {
+ userId,
+ type: { in: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] },
+ deletedAt: null,
+ measuredAt: { gte: windowStart, lt: candidate.measuredAt },
+ },
+ orderBy: { measuredAt: "desc" },
+ take: 50,
+ select: { type: true, value: true, measuredAt: true },
+ });
+
+ const recent = pairBp(rows);
+ const decision = evaluateBloodPressure({ candidate, recent, symptomCoupled });
+ await notifySafetyFloor({ userId, decision });
+}
+
+/**
+ * Pair loose SYS/DIA rows into whole BP samples by exact timestamp. A combined
+ * BP form writes both arms at the same instant, so an exact-instant join is
+ * the right grain here (the confirm gate only needs prior WHOLE readings).
+ */
+function pairBp(
+ rows: ReadonlyArray<{ type: string; value: number; measuredAt: Date }>,
+): BpSample[] {
+ const sysByTs = new Map();
+ const diaByTs = new Map();
+ for (const r of rows) {
+ const ts = r.measuredAt.getTime();
+ if (r.type === "BLOOD_PRESSURE_SYS") sysByTs.set(ts, r.value);
+ else if (r.type === "BLOOD_PRESSURE_DIA") diaByTs.set(ts, r.value);
+ }
+ const out: BpSample[] = [];
+ for (const [ts, systolic] of sysByTs) {
+ const diastolic = diaByTs.get(ts);
+ if (diastolic === undefined) continue;
+ out.push({ measuredAt: new Date(ts), systolic, diastolic });
+ }
+ return out;
+}
+
+/* ── glucose ──────────────────────────────────────────────────────────── */
+
+async function checkGlucose(
+ userId: string,
+ written: WrittenReading[],
+ symptomCoupled: boolean,
+): Promise {
+ const glucose = written.find((r) => r.type === "BLOOD_GLUCOSE");
+ if (!glucose) return;
+
+ // Module gate: only run when the glucose module is enabled for this account.
+ if (!(await isModuleEnabled(userId, "glucose"))) return;
+
+ const candidate: GlucoseSample = {
+ measuredAt: glucose.measuredAt,
+ mgdl: glucose.value,
+ };
+
+ const windowStart = new Date(
+ candidate.measuredAt.getTime() - CONFIRM_WINDOW_MS,
+ );
+ const rows = await prisma.measurement.findMany({
+ where: {
+ userId,
+ type: "BLOOD_GLUCOSE",
+ deletedAt: null,
+ measuredAt: { gte: windowStart, lt: candidate.measuredAt },
+ },
+ orderBy: { measuredAt: "desc" },
+ take: 50,
+ select: { value: true, measuredAt: true },
+ });
+
+ const recent: GlucoseSample[] = rows.map((r) => ({
+ measuredAt: r.measuredAt,
+ mgdl: r.value,
+ }));
+ const decision = evaluateGlucose({ candidate, recent, symptomCoupled });
+ await notifySafetyFloor({ userId, decision });
+}
diff --git a/src/lib/illness/safety-floor-notify.ts b/src/lib/illness/safety-floor-notify.ts
new file mode 100644
index 000000000..5e206088b
--- /dev/null
+++ b/src/lib/illness/safety-floor-notify.ts
@@ -0,0 +1,123 @@
+/**
+ * Absolute safety-floor escalation push (v1.18.6).
+ *
+ * Bridges a {@link SafetyFloorDecision} (from `safety-floors.ts`, already
+ * confirm-gated) to the notification dispatcher at the URGENT level, reusing
+ * the same urgent SYSTEM_ALERT + 24h-dedup mechanics as the illness red-flag
+ * push (`red-flag-notify.ts`).
+ *
+ * Copy framing (firm, safety-reviewed):
+ * - NEVER diagnoses (no "hypertensive emergency", no "DKA").
+ * - ASYMPTOMATIC breach → "re-check and contact your doctor" copy.
+ * - SYMPTOM-COUPLED breach → emergency copy ("call emergency services").
+ * - The body always echoes the (already re-confirmed) reading value.
+ *
+ * Dedup: at most one push per (user, reason) per rolling 24h window, anchored
+ * on a synthetic `pushAttempt` row keyed by reason — so a user re-checking a
+ * stubbornly-high reading repeatedly doesn't get spammed. Owner-scoped, never
+ * throws (a notification failure must not break the measurement write).
+ */
+import { prisma } from "@/lib/db";
+import { getEvent } from "@/lib/logging/context";
+import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised";
+import type {
+ SafetyFloorDecision,
+ SafetyFloorReason,
+} from "@/lib/illness/safety-floors";
+
+/** One escalation per (user, reason) per this window. */
+const DEDUPE_WINDOW_MS = 24 * 60 * 60 * 1000;
+
+/** Stable ledger reason prefix so the dedupe lookup is exact-matchable. */
+const LEDGER_REASON_PREFIX = "safety_floor:";
+
+/**
+ * The i18n title/body keys for a decision. Two body variants per reason: the
+ * asymptomatic "contact your doctor" copy and the symptom-coupled emergency
+ * copy. Every key below resolves in all six locale bundles (i18n integrity
+ * guarantee enforced by the locale-integrity test).
+ */
+function copyKeysFor(decision: SafetyFloorDecision): {
+ titleKey: string;
+ messageKey: string;
+} {
+ const variant = decision.symptomCoupled ? "Emergency" : "Doctor";
+ const reasonKey: Record = {
+ bp_hypertensive: "bpHigh",
+ bp_hypotensive: "bpLow",
+ glucose_hypo: "glucoseLow",
+ glucose_hypo_severe: "glucoseLowSevere",
+ glucose_hyper: "glucoseHigh",
+ };
+ return {
+ titleKey: `safety.floor.${reasonKey[decision.reason]}Title`,
+ messageKey: `safety.floor.${reasonKey[decision.reason]}${variant}`,
+ };
+}
+
+/** Body params — the re-confirmed reading value(s) echoed into the copy. */
+function paramsFor(
+ decision: SafetyFloorDecision,
+): Record {
+ if (decision.kind === "BLOOD_PRESSURE") {
+ return {
+ systolic: decision.value,
+ diastolic: decision.diastolic ?? 0,
+ };
+ }
+ return { value: decision.value };
+}
+
+/**
+ * Emit an urgent escalation push for a confirmed safety-floor breach, at most
+ * once per (user, reason) per `DEDUPE_WINDOW_MS`. No-op when `decision` is
+ * null. Never throws.
+ */
+export async function notifySafetyFloor(input: {
+ userId: string;
+ decision: SafetyFloorDecision | null;
+}): Promise {
+ const { userId, decision } = input;
+ if (!decision) return;
+
+ try {
+ const reason = `${LEDGER_REASON_PREFIX}${decision.reason}`;
+ const since = new Date(Date.now() - DEDUPE_WINDOW_MS);
+ const prior = await prisma.pushAttempt.findFirst({
+ where: { userId, reason, createdAt: { gte: since } },
+ select: { id: true },
+ });
+ if (prior) return;
+
+ // Stamp the ledger BEFORE dispatching so a concurrent confirm can't
+ // double-fire. The senders write their own per-channel rows; this
+ // synthetic row is purely the dedupe anchor.
+ await prisma.pushAttempt.create({
+ data: {
+ userId,
+ channel: "WEB_PUSH",
+ eventType: "SYSTEM_ALERT",
+ result: "skipped",
+ reason,
+ },
+ });
+
+ const { titleKey, messageKey } = copyKeysFor(decision);
+ await dispatchLocalisedNotification({
+ userId,
+ titleKey,
+ messageKey,
+ params: paramsFor(decision),
+ eventType: "SYSTEM_ALERT",
+ urgent: true,
+ });
+
+ getEvent()?.addMeta("safety_floor_notified", decision.reason);
+ } catch (err) {
+ getEvent()?.addWarning(
+ `safety-floor notify failed (${decision.reason}): ${
+ err instanceof Error ? err.message : String(err)
+ }`,
+ );
+ }
+}
diff --git a/src/lib/illness/safety-floors.ts b/src/lib/illness/safety-floors.ts
new file mode 100644
index 000000000..609e9c38e
--- /dev/null
+++ b/src/lib/illness/safety-floors.ts
@@ -0,0 +1,242 @@
+/**
+ * Absolute clinical safety floors (v1.18.6) — confirm-before-alarm engine.
+ *
+ * The illness *correlation* engine (`correlation.ts`) is retrospective: it
+ * flags a SUSTAINED multi-day adverse run against a user's OWN baseline. This
+ * module is its safety-critical complement: it evaluates a SINGLE freshly
+ * logged reading against ABSOLUTE, guideline-backed clinical floors and, when
+ * a reading breaches one, decides whether to escalate — but ONLY after a
+ * second, confirming reading (the AHA "wait and re-measure" discipline), so a
+ * single oscillometric spike or a one-off finger-stick can never raise a
+ * false alarm.
+ *
+ * Hard design rules (this is health-safety code — conservative by construction):
+ *
+ * 1. CONFIRM BEFORE ALARM. A breach reading alone is NEVER enough. We
+ * require a PRIOR reading of the same kind inside a short re-test window
+ * that ALSO breached the same floor. One reading → "watch", two → alarm.
+ * (Severe-tier floors keep the same confirm gate — the differentiator
+ * between tiers is the copy, not whether we wait.)
+ * 2. NEVER DIAGNOSE. The copy says "this reading is very high/low — re-check
+ * and, if it holds, contact your doctor / call emergency services". It
+ * never names a condition (no "hypertensive emergency", no "DKA").
+ * 3. SYMPTOM-COUPLED → emergency copy; ASYMPTOMATIC → contact-doctor copy.
+ * The caller passes whether the user flagged symptoms with the reading.
+ * 4. MODULE-GATED. The caller only runs the relevant check when the module
+ * is enabled (glucose module for glucose, the always-on BP core for BP).
+ *
+ * Pure given its inputs — no Prisma, no I/O. The caller resolves the recent
+ * same-kind readings + the symptom flag and passes them in; the dispatch +
+ * 24h dedup lives in `safety-floor-notify.ts`. mg/dL + mmHg throughout
+ * (HealthLog canonical store units).
+ *
+ * Citations (general guidance, not medical advice; wide individual variation):
+ * - BP ≥180/120: ACC/AHA 2017; AHA "Management of Elevated BP in the Acute
+ * Care Setting" 2024. The number is identical for emergency vs non-
+ * emergency — symptoms are the differentiator, not the value. Protocol:
+ * re-measure before acting.
+ * - Low BP (SBP < 90): NHLBI low-blood-pressure guidance; Hypotension —
+ * StatPearls/NIH 2024. Cautionary by default; emergency only with self-
+ * reported shock-type symptoms.
+ * - Hypoglycemia: ADA Standards of Care §6 — Level 1 alert < 70 mg/dL,
+ * Level 2 (clinically significant) < 54 mg/dL. Stable 2024–2026.
+ * - Hyperglycemia / DKA: ADA Standards of Care 2026 — DKA hyperglycemia
+ * criterion lowered to ≥ 200 mg/dL; ~10% of DKA is euglycemic, so a
+ * glucose value alone cannot rule it out — we NEVER show "all clear" below
+ * 200 and reserve the urgent escalation for a sustained very-high band
+ * (≥ 250 mg/dL) per the practical seek-care trigger.
+ */
+
+/** Reading kinds the absolute-floor engine evaluates. */
+export type SafetyFloorKind = "BLOOD_PRESSURE" | "GLUCOSE";
+
+/** Why a reading breached a floor — a stable key the i18n layer renders. */
+export type SafetyFloorReason =
+ | "bp_hypertensive" // SBP ≥ 180 OR DBP ≥ 120
+ | "bp_hypotensive" // SBP < 90
+ | "glucose_hypo" // < 70 mg/dL
+ | "glucose_hypo_severe" // < 54 mg/dL
+ | "glucose_hyper"; // ≥ 250 mg/dL (sustained-very-high seek-care trigger)
+
+/** Severity tier — drives copy, never the confirm gate. */
+export type SafetyFloorTier = "caution" | "severe";
+
+/* ── absolute floors (documented clinical constants) ─────────────────── */
+
+/** Hypertensive-urgency floor: systolic ≥ this OR diastolic ≥ DIA floor. */
+export const BP_SYS_HYPERTENSIVE = 180;
+export const BP_DIA_HYPERTENSIVE = 120;
+/** Symmetric low-BP cautionary floor: systolic < this. */
+export const BP_SYS_HYPOTENSIVE = 90;
+
+/** Hypoglycemia Level-1 alert: glucose < this (mg/dL). */
+export const GLUCOSE_HYPO = 70;
+/** Hypoglycemia Level-2 (clinically significant): glucose < this (mg/dL). */
+export const GLUCOSE_HYPO_SEVERE = 54;
+/**
+ * Hyperglycemia urgent escalation floor (mg/dL). ADA 2026 lowered the DKA
+ * hyperglycemia CRITERION to ≥ 200, but a value alone cannot rule DKA in OR
+ * out (euglycemic DKA exists), so we do NOT urgently escalate at 200. We
+ * reserve the escalation for the practical "sustained very-high" seek-care
+ * trigger (≥ 250) and the copy NEVER reads "all clear" below 200.
+ */
+export const GLUCOSE_HYPER = 250;
+
+/**
+ * Confirm window: a breach reading only escalates when a PRIOR same-kind
+ * reading inside this window ALSO breached the SAME floor. Long enough that a
+ * deliberate "wait a minute and re-measure" is captured, short enough that two
+ * unrelated readings days apart don't spuriously confirm each other.
+ */
+export const CONFIRM_WINDOW_MS = 60 * 60 * 1000; // 1 hour
+
+/* ── inputs (the caller resolves these and passes them in) ───────────── */
+
+/** A blood-pressure reading (systolic + diastolic, mmHg) at an instant. */
+export interface BpSample {
+ measuredAt: Date;
+ systolic: number;
+ diastolic: number;
+}
+
+/** A spot glucose reading (mg/dL canonical) at an instant. */
+export interface GlucoseSample {
+ measuredAt: Date;
+ mgdl: number;
+}
+
+/** The escalation decision the engine returns (or null = nothing to do). */
+export interface SafetyFloorDecision {
+ kind: SafetyFloorKind;
+ reason: SafetyFloorReason;
+ tier: SafetyFloorTier;
+ /** True when the user flagged symptoms → emergency-tier copy. */
+ symptomCoupled: boolean;
+ /** The breaching value(s), for the localised body params. */
+ value: number;
+ /** The diastolic value when `kind === "BLOOD_PRESSURE"` (else null). */
+ diastolic: number | null;
+}
+
+/* ── BP evaluation ────────────────────────────────────────────────────── */
+
+/** Classify a single BP reading against the absolute floors. */
+function classifyBp(
+ sys: number,
+ dia: number,
+): { reason: SafetyFloorReason; tier: SafetyFloorTier } | null {
+ if (sys >= BP_SYS_HYPERTENSIVE || dia >= BP_DIA_HYPERTENSIVE) {
+ return { reason: "bp_hypertensive", tier: "severe" };
+ }
+ if (sys < BP_SYS_HYPOTENSIVE) {
+ // Symmetric low-BP is cautionary by default — most hypotension is benign;
+ // it only becomes urgent when symptom-coupled (handled by the caller's
+ // symptom flag, which lifts the copy to emergency tier).
+ return { reason: "bp_hypotensive", tier: "caution" };
+ }
+ return null;
+}
+
+/**
+ * Evaluate a fresh BP reading against the absolute floors with the confirm
+ * gate. Returns a decision ONLY when the candidate breaches AND a prior
+ * reading inside the confirm window breached the SAME reason. `recent` is the
+ * user's other same-kind readings (excluding the candidate); order-independent.
+ */
+export function evaluateBloodPressure(input: {
+ candidate: BpSample;
+ recent: BpSample[];
+ symptomCoupled: boolean;
+}): SafetyFloorDecision | null {
+ const { candidate, recent, symptomCoupled } = input;
+ const candClass = classifyBp(candidate.systolic, candidate.diastolic);
+ if (!candClass) return null;
+
+ const windowStart = candidate.measuredAt.getTime() - CONFIRM_WINDOW_MS;
+ const confirmed = recent.some((r) => {
+ const t = r.measuredAt.getTime();
+ if (t < windowStart || t > candidate.measuredAt.getTime()) return false;
+ const c = classifyBp(r.systolic, r.diastolic);
+ return c?.reason === candClass.reason;
+ });
+ if (!confirmed) return null;
+
+ return {
+ kind: "BLOOD_PRESSURE",
+ reason: candClass.reason,
+ // A symptom-coupled hypotensive breach escalates to severe-tier copy
+ // (shock signs → emergency); asymptomatic stays cautionary.
+ tier:
+ candClass.reason === "bp_hypotensive" && symptomCoupled
+ ? "severe"
+ : candClass.tier,
+ symptomCoupled,
+ value: candidate.systolic,
+ diastolic: candidate.diastolic,
+ };
+}
+
+/* ── glucose evaluation ───────────────────────────────────────────────── */
+
+/** Classify a single glucose reading (mg/dL) against the absolute floors. */
+function classifyGlucose(
+ mgdl: number,
+): { reason: SafetyFloorReason; tier: SafetyFloorTier } | null {
+ if (mgdl < GLUCOSE_HYPO_SEVERE) {
+ return { reason: "glucose_hypo_severe", tier: "severe" };
+ }
+ if (mgdl < GLUCOSE_HYPO) {
+ return { reason: "glucose_hypo", tier: "caution" };
+ }
+ if (mgdl >= GLUCOSE_HYPER) {
+ return { reason: "glucose_hyper", tier: "severe" };
+ }
+ return null;
+}
+
+/**
+ * Evaluate a fresh glucose reading against the absolute floors with the
+ * confirm gate. The confirm match is by FLOOR FAMILY, not exact reason: a
+ * follow-up reading that is hypo (< 70) confirms a candidate that is
+ * severe-hypo (< 54) and vice versa — both are "the low floor held on
+ * re-test". The same applies to the high floor. Hypoglycemia is acutely
+ * dangerous, so a severe-hypo candidate confirmed by ANY low re-test still
+ * escalates at the severe tier (the candidate's own tier wins).
+ */
+export function evaluateGlucose(input: {
+ candidate: GlucoseSample;
+ recent: GlucoseSample[];
+ symptomCoupled: boolean;
+}): SafetyFloorDecision | null {
+ const { candidate, recent, symptomCoupled } = input;
+ const candClass = classifyGlucose(candidate.mgdl);
+ if (!candClass) return null;
+
+ const family = floorFamily(candClass.reason);
+ const windowStart = candidate.measuredAt.getTime() - CONFIRM_WINDOW_MS;
+ const confirmed = recent.some((r) => {
+ const t = r.measuredAt.getTime();
+ if (t < windowStart || t > candidate.measuredAt.getTime()) return false;
+ const c = classifyGlucose(r.mgdl);
+ return c != null && floorFamily(c.reason) === family;
+ });
+ if (!confirmed) return null;
+
+ return {
+ kind: "GLUCOSE",
+ reason: candClass.reason,
+ tier: candClass.tier,
+ symptomCoupled,
+ value: Math.round(candidate.mgdl),
+ diastolic: null,
+ };
+}
+
+/** Group a glucose reason into its directional floor family for confirm-matching. */
+function floorFamily(reason: SafetyFloorReason): "low" | "high" | "other" {
+ if (reason === "glucose_hypo" || reason === "glucose_hypo_severe") {
+ return "low";
+ }
+ if (reason === "glucose_hyper") return "high";
+ return "other";
+}
diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts
index a2b48acbf..1e39e7cd5 100644
--- a/src/lib/validations/measurement.ts
+++ b/src/lib/validations/measurement.ts
@@ -575,6 +575,12 @@ export const createMeasurementSchema = z
// metadata) now persist the tag instead of seeing it disappear.
// Accepts `null` so a client can explicitly clear the column.
deviceType: z.string().min(1).max(32).nullable().optional(),
+ // v1.18.6 — optional "I feel unwell with this reading" flag. Transient
+ // (NOT persisted on the measurement row): it only lifts a confirmed
+ // safety-floor escalation from the asymptomatic "contact your doctor"
+ // copy to the symptom-coupled emergency copy. Defaults to false/absent —
+ // every existing client keeps the asymptomatic posture. Never a diagnosis.
+ symptomsPresent: z.boolean().optional(),
})
.refine((data) => validateMeasurementRange(data.type, data.value) === null, {
message: "Value out of plausible range",
From 961257fffdbd87c3b82f2a28c8866680aefa831d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 20:12:16 +0200
Subject: [PATCH 40/71] i18n: add safety-floor alert and diabetes-target
strings
Add the safety-floor escalation copy (per-reason title plus a contact-your-
doctor and an emergency body variant) and the diabetes opt-in setting strings
across all six locales. The alert copy frames as general guidance, never names
a condition, and routes symptom-coupled breaches to the emergency wording.
---
messages/de.json | 24 +++++++++++++++++++++++-
messages/en.json | 24 +++++++++++++++++++++++-
messages/es.json | 24 +++++++++++++++++++++++-
messages/fr.json | 24 +++++++++++++++++++++++-
messages/it.json | 24 +++++++++++++++++++++++-
messages/pl.json | 24 +++++++++++++++++++++++-
6 files changed, 138 insertions(+), 6 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index d9fab76e8..0401f087a 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4725,7 +4725,10 @@
"savedOff": "Coach ist ausgeblendet.",
"saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen."
}
- }
+ },
+ "diabetes": "Ich habe Diabetes",
+ "diabetesDescription": "Nutze ärztliche Glukose-Zielwerte (nüchtern 80–130, nach dem Essen unter 180 mg/dL) statt der allgemeinen Bänder. Das ändert nur den Zielbereich, an dem du gemessen wirst — es ist keine Diagnose.",
+ "diabetesSaved": "Glukose-Zielwert-Einstellung gespeichert"
},
"admin": {
"title": "Administration",
@@ -6760,5 +6763,24 @@
"reminderSuggestionsLabel": "Kadenz-Vorschläge",
"reminderSuggestionsDescription": "Lass den Coach gelegentlich eine evidenzbasierte Erinnerung anbieten, eine Metrik regelmäßiger zu messen."
}
+ },
+ "safety": {
+ "floor": {
+ "bpHighTitle": "Sehr hoher Blutdruck",
+ "bpHighDoctor": "Zwei aktuelle Messungen lagen bei {systolic}/{diastolic} mmHg oder höher. Ruhe dich aus, miss in einigen Minuten erneut und kontaktiere möglichst bald deine Ärztin oder deinen Arzt, wenn es so hoch bleibt.",
+ "bpHighEmergency": "Zwei aktuelle Messungen lagen bei {systolic}/{diastolic} mmHg oder höher und du hast Beschwerden angegeben. Bei Brustschmerz, Atemnot, Schwäche oder Seh- oder Sprachstörungen sofort den Notruf wählen.",
+ "bpLowTitle": "Niedriger Blutdruck",
+ "bpLowDoctor": "Zwei aktuelle Messungen hatten einen systolischen Wert unter {systolic} mmHg. Wenn dir schwindelig oder schummrig ist, setz oder leg dich hin und kontaktiere deine Ärztin oder deinen Arzt, wenn es anhält.",
+ "bpLowEmergency": "Zwei aktuelle Messungen hatten einen systolischen Wert unter {systolic} mmHg und du hast Beschwerden angegeben. Bei Ohnmachtsgefühl, Verwirrtheit oder kalter, feuchter Haut sofort den Notruf wählen.",
+ "glucoseLowTitle": "Niedriger Blutzucker",
+ "glucoseLowDoctor": "Zwei aktuelle Messungen lagen unter 70 mg/dL ({value} mg/dL). Nimm schnell wirkende Kohlenhydrate, miss in 15 Minuten erneut und kontaktiere deine Ärztin oder deinen Arzt, wenn die Tiefs anhalten.",
+ "glucoseLowEmergency": "Zwei aktuelle Messungen lagen unter 70 mg/dL ({value} mg/dL) und du hast Beschwerden angegeben. Behandle jetzt mit schnellem Zucker; wenn das nicht geht oder dir sehr unwohl ist, wähle den Notruf.",
+ "glucoseLowSevereTitle": "Sehr niedriger Blutzucker",
+ "glucoseLowSevereDoctor": "Zwei aktuelle Messungen lagen unter 54 mg/dL ({value} mg/dL). Behandle sofort mit schnell wirkenden Kohlenhydraten, miss in 15 Minuten erneut und kontaktiere deine Ärztin oder deinen Arzt.",
+ "glucoseLowSevereEmergency": "Zwei aktuelle Messungen lagen unter 54 mg/dL ({value} mg/dL) und du hast Beschwerden angegeben. Behandle jetzt; wenn du nichts bei dir behalten kannst oder dir sehr unwohl ist, wähle den Notruf.",
+ "glucoseHighTitle": "Sehr hoher Blutzucker",
+ "glucoseHighDoctor": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher. Miss erneut, trink ausreichend und kontaktiere möglichst bald deine Ärztin oder deinen Arzt, wenn es so hoch bleibt.",
+ "glucoseHighEmergency": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher und du hast Beschwerden angegeben. Bei Erbrechen, schneller Atmung oder Verwirrtheit sofort den Notruf wählen."
+ }
}
}
diff --git a/messages/en.json b/messages/en.json
index eed712aac..6057b30dc 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4725,7 +4725,10 @@
"savedOff": "Coach hidden.",
"saveError": "Could not save the change. Please try again."
}
- }
+ },
+ "diabetes": "I have diabetes",
+ "diabetesDescription": "Use clinician glucose targets (fasting 80–130, post-meal under 180 mg/dL) instead of the general bands. This only changes the target you are measured against — it is never a diagnosis.",
+ "diabetesSaved": "Glucose target preference saved"
},
"admin": {
"title": "Administration",
@@ -6760,5 +6763,24 @@
"reminderSuggestionsLabel": "Cadence suggestions",
"reminderSuggestionsDescription": "Let the Coach offer an occasional, evidence-based reminder to measure a metric more regularly."
}
+ },
+ "safety": {
+ "floor": {
+ "bpHighTitle": "Very high blood pressure",
+ "bpHighDoctor": "Two recent readings were {systolic}/{diastolic} mmHg or higher. Rest, re-check in a few minutes, and contact your doctor as soon as possible if it stays this high.",
+ "bpHighEmergency": "Two recent readings were {systolic}/{diastolic} mmHg or higher and you noted symptoms. If you have chest pain, breathlessness, weakness, or vision or speech changes, call emergency services now.",
+ "bpLowTitle": "Low blood pressure",
+ "bpLowDoctor": "Two recent readings had a systolic below {systolic} mmHg. If you feel dizzy or faint, sit or lie down, and contact your doctor if it continues.",
+ "bpLowEmergency": "Two recent readings had a systolic below {systolic} mmHg and you noted symptoms. If you feel faint, confused, or have cold clammy skin, call emergency services now.",
+ "glucoseLowTitle": "Low blood sugar",
+ "glucoseLowDoctor": "Two recent readings were below 70 mg/dL ({value} mg/dL). Take fast-acting carbohydrate, re-check in 15 minutes, and contact your doctor if lows continue.",
+ "glucoseLowEmergency": "Two recent readings were below 70 mg/dL ({value} mg/dL) and you noted symptoms. Treat now with fast-acting sugar; if you cannot, or you feel very unwell, call emergency services.",
+ "glucoseLowSevereTitle": "Very low blood sugar",
+ "glucoseLowSevereDoctor": "Two recent readings were below 54 mg/dL ({value} mg/dL). Treat immediately with fast-acting carbohydrate, re-check in 15 minutes, and contact your doctor.",
+ "glucoseLowSevereEmergency": "Two recent readings were below 54 mg/dL ({value} mg/dL) and you noted symptoms. Treat now; if you cannot keep food down or feel very unwell, call emergency services.",
+ "glucoseHighTitle": "Very high blood sugar",
+ "glucoseHighDoctor": "Two recent readings were {value} mg/dL or higher. Re-check, hydrate, and contact your doctor as soon as possible if it stays this high.",
+ "glucoseHighEmergency": "Two recent readings were {value} mg/dL or higher and you noted symptoms. If you have vomiting, fast breathing, or confusion, call emergency services now."
+ }
}
}
diff --git a/messages/es.json b/messages/es.json
index 2bafb2f5c..3370b034e 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4725,7 +4725,10 @@
"savedOff": "Coach oculto.",
"saveError": "No se pudo guardar el cambio. Inténtalo de nuevo."
}
- }
+ },
+ "diabetes": "Tengo diabetes",
+ "diabetesDescription": "Usa objetivos de glucosa clínicos (en ayunas 80–130, tras la comida menos de 180 mg/dL) en lugar de las bandas generales. Esto solo cambia el objetivo con el que te comparas; nunca es un diagnóstico.",
+ "diabetesSaved": "Preferencia de objetivo de glucosa guardada"
},
"admin": {
"title": "Administration",
@@ -6760,5 +6763,24 @@
"reminderSuggestionsLabel": "Sugerencias de frecuencia",
"reminderSuggestionsDescription": "Permite que el Coach ofrezca de vez en cuando un recordatorio basado en evidencia para medir una métrica con más regularidad."
}
+ },
+ "safety": {
+ "floor": {
+ "bpHighTitle": "Presión arterial muy alta",
+ "bpHighDoctor": "Dos lecturas recientes fueron de {systolic}/{diastolic} mmHg o más. Descansa, vuelve a medir en unos minutos y contacta a tu médico cuanto antes si sigue así de alta.",
+ "bpHighEmergency": "Dos lecturas recientes fueron de {systolic}/{diastolic} mmHg o más e indicaste síntomas. Si tienes dolor en el pecho, falta de aire, debilidad o cambios en la visión o el habla, llama a emergencias ya.",
+ "bpLowTitle": "Presión arterial baja",
+ "bpLowDoctor": "Dos lecturas recientes tuvieron una sistólica por debajo de {systolic} mmHg. Si te mareas o te sientes débil, siéntate o recuéstate y contacta a tu médico si continúa.",
+ "bpLowEmergency": "Dos lecturas recientes tuvieron una sistólica por debajo de {systolic} mmHg e indicaste síntomas. Si te sientes a punto de desmayar, confuso o con piel fría y húmeda, llama a emergencias ya.",
+ "glucoseLowTitle": "Glucosa baja",
+ "glucoseLowDoctor": "Dos lecturas recientes estuvieron por debajo de 70 mg/dL ({value} mg/dL). Toma carbohidratos de acción rápida, vuelve a medir en 15 minutos y contacta a tu médico si los bajones continúan.",
+ "glucoseLowEmergency": "Dos lecturas recientes estuvieron por debajo de 70 mg/dL ({value} mg/dL) e indicaste síntomas. Trátalo ahora con azúcar rápida; si no puedes o te sientes muy mal, llama a emergencias.",
+ "glucoseLowSevereTitle": "Glucosa muy baja",
+ "glucoseLowSevereDoctor": "Dos lecturas recientes estuvieron por debajo de 54 mg/dL ({value} mg/dL). Trátalo de inmediato con carbohidratos de acción rápida, vuelve a medir en 15 minutos y contacta a tu médico.",
+ "glucoseLowSevereEmergency": "Dos lecturas recientes estuvieron por debajo de 54 mg/dL ({value} mg/dL) e indicaste síntomas. Trátalo ahora; si no retienes alimentos o te sientes muy mal, llama a emergencias.",
+ "glucoseHighTitle": "Glucosa muy alta",
+ "glucoseHighDoctor": "Dos lecturas recientes fueron de {value} mg/dL o más. Vuelve a medir, hidrátate y contacta a tu médico cuanto antes si sigue así de alta.",
+ "glucoseHighEmergency": "Dos lecturas recientes fueron de {value} mg/dL o más e indicaste síntomas. Si tienes vómitos, respiración rápida o confusión, llama a emergencias ya."
+ }
}
}
diff --git a/messages/fr.json b/messages/fr.json
index 591813a41..310006f4b 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4725,7 +4725,10 @@
"savedOff": "Coach masqué.",
"saveError": "Impossible d enregistrer la modification. Veuillez réessayer."
}
- }
+ },
+ "diabetes": "Je suis diabétique",
+ "diabetesDescription": "Utilise des objectifs glycémiques cliniques (à jeun 80–130, après le repas moins de 180 mg/dL) au lieu des plages générales. Cela ne change que l'objectif auquel tu es comparé — ce n'est jamais un diagnostic.",
+ "diabetesSaved": "Préférence d'objectif glycémique enregistrée"
},
"admin": {
"title": "Administration",
@@ -6760,5 +6763,24 @@
"reminderSuggestionsLabel": "Suggestions de fréquence",
"reminderSuggestionsDescription": "Laissez le Coach proposer de temps en temps un rappel fondé sur des preuves pour mesurer une donnée plus régulièrement."
}
+ },
+ "safety": {
+ "floor": {
+ "bpHighTitle": "Tension artérielle très élevée",
+ "bpHighDoctor": "Deux mesures récentes étaient à {systolic}/{diastolic} mmHg ou plus. Repose-toi, remesure dans quelques minutes et contacte ton médecin dès que possible si cela reste aussi élevé.",
+ "bpHighEmergency": "Deux mesures récentes étaient à {systolic}/{diastolic} mmHg ou plus et tu as signalé des symptômes. En cas de douleur thoracique, d'essoufflement, de faiblesse ou de troubles de la vision ou de la parole, appelle les secours immédiatement.",
+ "bpLowTitle": "Tension artérielle basse",
+ "bpLowDoctor": "Deux mesures récentes avaient une systolique inférieure à {systolic} mmHg. Si tu te sens étourdi ou faible, assieds-toi ou allonge-toi et contacte ton médecin si cela persiste.",
+ "bpLowEmergency": "Deux mesures récentes avaient une systolique inférieure à {systolic} mmHg et tu as signalé des symptômes. Si tu te sens près de t'évanouir, confus ou as la peau froide et moite, appelle les secours immédiatement.",
+ "glucoseLowTitle": "Glycémie basse",
+ "glucoseLowDoctor": "Deux mesures récentes étaient en dessous de 70 mg/dL ({value} mg/dL). Prends des glucides à action rapide, remesure dans 15 minutes et contacte ton médecin si les baisses persistent.",
+ "glucoseLowEmergency": "Deux mesures récentes étaient en dessous de 70 mg/dL ({value} mg/dL) et tu as signalé des symptômes. Traite maintenant avec du sucre rapide ; si tu ne peux pas ou te sens très mal, appelle les secours.",
+ "glucoseLowSevereTitle": "Glycémie très basse",
+ "glucoseLowSevereDoctor": "Deux mesures récentes étaient en dessous de 54 mg/dL ({value} mg/dL). Traite immédiatement avec des glucides à action rapide, remesure dans 15 minutes et contacte ton médecin.",
+ "glucoseLowSevereEmergency": "Deux mesures récentes étaient en dessous de 54 mg/dL ({value} mg/dL) et tu as signalé des symptômes. Traite maintenant ; si tu ne gardes pas la nourriture ou te sens très mal, appelle les secours.",
+ "glucoseHighTitle": "Glycémie très élevée",
+ "glucoseHighDoctor": "Deux mesures récentes étaient à {value} mg/dL ou plus. Remesure, hydrate-toi et contacte ton médecin dès que possible si cela reste aussi élevé.",
+ "glucoseHighEmergency": "Deux mesures récentes étaient à {value} mg/dL ou plus et tu as signalé des symptômes. En cas de vomissements, de respiration rapide ou de confusion, appelle les secours immédiatement."
+ }
}
}
diff --git a/messages/it.json b/messages/it.json
index 2a850296e..b881a4956 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4725,7 +4725,10 @@
"savedOff": "Coach nascosto.",
"saveError": "Impossibile salvare la modifica. Riprova."
}
- }
+ },
+ "diabetes": "Ho il diabete",
+ "diabetesDescription": "Usa obiettivi glicemici clinici (a digiuno 80–130, dopo i pasti sotto 180 mg/dL) invece delle bande generali. Questo cambia solo l'obiettivo con cui ti confronti — non è mai una diagnosi.",
+ "diabetesSaved": "Preferenza dell'obiettivo glicemico salvata"
},
"admin": {
"title": "Administration",
@@ -6760,5 +6763,24 @@
"reminderSuggestionsLabel": "Suggerimenti di cadenza",
"reminderSuggestionsDescription": "Lascia che il Coach proponga ogni tanto un promemoria basato sull'evidenza per misurare una metrica più regolarmente."
}
+ },
+ "safety": {
+ "floor": {
+ "bpHighTitle": "Pressione molto alta",
+ "bpHighDoctor": "Due misurazioni recenti erano di {systolic}/{diastolic} mmHg o superiori. Riposa, rimisura tra qualche minuto e contatta il medico al più presto se resta così alta.",
+ "bpHighEmergency": "Due misurazioni recenti erano di {systolic}/{diastolic} mmHg o superiori e hai segnalato sintomi. In caso di dolore al petto, affanno, debolezza o disturbi della vista o del linguaggio, chiama subito i soccorsi.",
+ "bpLowTitle": "Pressione bassa",
+ "bpLowDoctor": "Due misurazioni recenti avevano una sistolica sotto {systolic} mmHg. Se ti senti stordito o debole, siediti o sdraiati e contatta il medico se continua.",
+ "bpLowEmergency": "Due misurazioni recenti avevano una sistolica sotto {systolic} mmHg e hai segnalato sintomi. Se senti che stai per svenire, sei confuso o hai la pelle fredda e sudata, chiama subito i soccorsi.",
+ "glucoseLowTitle": "Glicemia bassa",
+ "glucoseLowDoctor": "Due misurazioni recenti erano sotto 70 mg/dL ({value} mg/dL). Assumi carboidrati ad azione rapida, rimisura tra 15 minuti e contatta il medico se i cali continuano.",
+ "glucoseLowEmergency": "Due misurazioni recenti erano sotto 70 mg/dL ({value} mg/dL) e hai segnalato sintomi. Tratta subito con zucchero rapido; se non puoi o ti senti molto male, chiama i soccorsi.",
+ "glucoseLowSevereTitle": "Glicemia molto bassa",
+ "glucoseLowSevereDoctor": "Due misurazioni recenti erano sotto 54 mg/dL ({value} mg/dL). Tratta subito con carboidrati ad azione rapida, rimisura tra 15 minuti e contatta il medico.",
+ "glucoseLowSevereEmergency": "Due misurazioni recenti erano sotto 54 mg/dL ({value} mg/dL) e hai segnalato sintomi. Tratta subito; se non trattieni il cibo o ti senti molto male, chiama i soccorsi.",
+ "glucoseHighTitle": "Glicemia molto alta",
+ "glucoseHighDoctor": "Due misurazioni recenti erano di {value} mg/dL o superiori. Rimisura, idratati e contatta il medico al più presto se resta così alta.",
+ "glucoseHighEmergency": "Due misurazioni recenti erano di {value} mg/dL o superiori e hai segnalato sintomi. In caso di vomito, respiro affannoso o confusione, chiama subito i soccorsi."
+ }
}
}
diff --git a/messages/pl.json b/messages/pl.json
index 345078605..82756d8fd 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4725,7 +4725,10 @@
"savedOff": "Coach ukryty.",
"saveError": "Nie udało się zapisać zmiany. Spróbuj ponownie."
}
- }
+ },
+ "diabetes": "Mam cukrzycę",
+ "diabetesDescription": "Użyj klinicznych celów glukozy (na czczo 80–130, po posiłku poniżej 180 mg/dL) zamiast ogólnych zakresów. Zmienia to tylko cel, względem którego jesteś oceniany — to nigdy nie jest diagnoza.",
+ "diabetesSaved": "Zapisano preferencję celu glukozy"
},
"admin": {
"title": "Administracja",
@@ -6760,5 +6763,24 @@
"reminderSuggestionsLabel": "Sugestie częstotliwości",
"reminderSuggestionsDescription": "Pozwól Coachowi od czasu do czasu zaproponować oparte na dowodach przypomnienie, aby mierzyć daną wartość bardziej regularnie."
}
+ },
+ "safety": {
+ "floor": {
+ "bpHighTitle": "Bardzo wysokie ciśnienie",
+ "bpHighDoctor": "Dwa ostatnie pomiary wyniosły {systolic}/{diastolic} mmHg lub więcej. Odpocznij, zmierz ponownie za kilka minut i jak najszybciej skontaktuj się z lekarzem, jeśli pozostaje tak wysokie.",
+ "bpHighEmergency": "Dwa ostatnie pomiary wyniosły {systolic}/{diastolic} mmHg lub więcej i zgłosiłeś objawy. W razie bólu w klatce piersiowej, duszności, osłabienia lub zaburzeń widzenia czy mowy natychmiast zadzwoń po pomoc.",
+ "bpLowTitle": "Niskie ciśnienie",
+ "bpLowDoctor": "Dwa ostatnie pomiary miały ciśnienie skurczowe poniżej {systolic} mmHg. Jeśli kręci ci się w głowie lub czujesz osłabienie, usiądź lub połóż się i skontaktuj się z lekarzem, jeśli to się utrzymuje.",
+ "bpLowEmergency": "Dwa ostatnie pomiary miały ciśnienie skurczowe poniżej {systolic} mmHg i zgłosiłeś objawy. Jeśli czujesz, że możesz zemdleć, jesteś zdezorientowany lub masz zimną, lepką skórę, natychmiast zadzwoń po pomoc.",
+ "glucoseLowTitle": "Niski poziom cukru",
+ "glucoseLowDoctor": "Dwa ostatnie pomiary były poniżej 70 mg/dL ({value} mg/dL). Przyjmij szybko działające węglowodany, zmierz ponownie za 15 minut i skontaktuj się z lekarzem, jeśli spadki się utrzymują.",
+ "glucoseLowEmergency": "Dwa ostatnie pomiary były poniżej 70 mg/dL ({value} mg/dL) i zgłosiłeś objawy. Lecz to teraz szybkim cukrem; jeśli nie możesz lub czujesz się bardzo źle, zadzwoń po pomoc.",
+ "glucoseLowSevereTitle": "Bardzo niski poziom cukru",
+ "glucoseLowSevereDoctor": "Dwa ostatnie pomiary były poniżej 54 mg/dL ({value} mg/dL). Lecz natychmiast szybko działającymi węglowodanami, zmierz ponownie za 15 minut i skontaktuj się z lekarzem.",
+ "glucoseLowSevereEmergency": "Dwa ostatnie pomiary były poniżej 54 mg/dL ({value} mg/dL) i zgłosiłeś objawy. Lecz to teraz; jeśli nie utrzymujesz pokarmu lub czujesz się bardzo źle, zadzwoń po pomoc.",
+ "glucoseHighTitle": "Bardzo wysoki poziom cukru",
+ "glucoseHighDoctor": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej. Zmierz ponownie, nawodnij się i jak najszybciej skontaktuj się z lekarzem, jeśli pozostaje tak wysoki.",
+ "glucoseHighEmergency": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej i zgłosiłeś objawy. W razie wymiotów, przyspieszonego oddechu lub dezorientacji natychmiast zadzwoń po pomoc."
+ }
}
}
From 22ffb19fa5dca5f6bba8de00d8ae583ff636c4fb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 20:32:45 +0200
Subject: [PATCH 41/71] feat(coach): ground replies in cited reference ranges
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Surface the population reference bands behind the metrics a Coach turn
carries as a compact, citation-aware grounding block appended after the
SNAPSHOT. For each present metric the block states the headline band, its
source (blood pressure on the ESH 2023 line, US/ESC framings as context
only), and where the user's value sits via the shared four-state
placement contract.
Framed strictly as general guidance: the block opens and closes with a
not-a-diagnosis caveat, never overrides the existing refusal/dose/drug-
level safety discipline, and carries no commercial brand name. Glucose
respects the diabetes opt-in — the tighter ADA management goal only when
the user has declared it, otherwise the general non-diabetic band.
The block is deterministic and inspectable: built purely from the
reference-range backbone over the same scalars the snapshot already
surfaces, so it can never cite a value or band the snapshot does not
carry.
---
src/app/api/insights/chat/route.ts | 11 +-
.../__tests__/reference-grounding.test.ts | 202 ++++++++++++++
src/lib/ai/coach/__tests__/snapshot.test.ts | 93 +++++++
src/lib/ai/coach/reference-grounding.ts | 252 ++++++++++++++++++
src/lib/ai/coach/snapshot.ts | 122 +++++++++
src/lib/ai/coach/system-prompt.ts | 35 +++
6 files changed, 714 insertions(+), 1 deletion(-)
create mode 100644 src/lib/ai/coach/__tests__/reference-grounding.test.ts
create mode 100644 src/lib/ai/coach/reference-grounding.ts
diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts
index 812c38b80..93311440c 100644
--- a/src/app/api/insights/chat/route.ts
+++ b/src/app/api/insights/chat/route.ts
@@ -393,9 +393,18 @@ The user's message answers this clarifying question from their self-context ques
React briefly and personally to the answer; do not repeat the question and do not ask it again.
`
: "";
+ // v1.18.6 (W7) — citation-aware reference-range grounding for the metrics
+ // present in this snapshot. Deterministic + brand-free; framed as general
+ // guidance, never a diagnosis. Appended after the SNAPSHOT so it is fully
+ // inspectable and the model reads the published population bands + the
+ // user's placement. Omitted entirely when no present metric is covered by
+ // the reference backbone (the builder returns null).
+ const groundingBlock = snapshot.referenceGrounding
+ ? `\n${snapshot.referenceGrounding}\n`
+ : "";
const userPrompt = `SNAPSHOT
${snapshot.snapshotJson || "(no metric data in this user's log yet)"}
-${guidedBlock}
+${groundingBlock}${guidedBlock}
CONVERSATION
${transcript}
diff --git a/src/lib/ai/coach/__tests__/reference-grounding.test.ts b/src/lib/ai/coach/__tests__/reference-grounding.test.ts
new file mode 100644
index 000000000..c04540a83
--- /dev/null
+++ b/src/lib/ai/coach/__tests__/reference-grounding.test.ts
@@ -0,0 +1,202 @@
+import { describe, expect, it } from "vitest";
+
+import { buildReferenceGroundingBlock } from "../reference-grounding";
+import { CITATIONS } from "@/lib/medical-citations";
+
+/**
+ * W7 — the Coach reference-grounding block. These tests pin the
+ * deterministic shape the hallucination-QA pass audits: the right band per
+ * metric, the right four-state placement, the general-guidance caveat, the
+ * ESH-2023 blood-pressure label, the diabetes opt-in gating, and the hard
+ * no-commercial-brand rule.
+ */
+
+/**
+ * Every commercial brand the Coach prompt already names elsewhere (the
+ * GLP-1 block / dose-deferral rule). The grounding block must contain NONE
+ * of them — it is brand-free by construction, and this list is the guard.
+ */
+const BRAND_TOKENS = [
+ "Mounjaro",
+ "Ozempic",
+ "Wegovy",
+ "Zepbound",
+ "Trulicity",
+ "Saxenda",
+ "Rybelsus",
+ "Eli Lilly",
+ "Novo Nordisk",
+ "Withings",
+ "WHOOP",
+ "Oura",
+ "Fitbit",
+ "Garmin",
+ "Apple",
+ "Polar",
+ "Nightscout",
+ "Dexcom",
+ "Libre",
+];
+
+describe("buildReferenceGroundingBlock", () => {
+ it("returns null when no metric is covered by the backbone", () => {
+ expect(buildReferenceGroundingBlock({ metrics: [], hasDiabetes: false })).toBeNull();
+ });
+
+ it("cites the ESH 2023 source for blood pressure and never the US/ESC line", () => {
+ const block = buildReferenceGroundingBlock({
+ metrics: [{ metric: "BLOOD_PRESSURE", value: 118 }],
+ hasDiabetes: false,
+ });
+ expect(block).not.toBeNull();
+ expect(block).toContain("ESH 2023");
+ // The headline normal band edge (≤120 mmHg) is surfaced.
+ expect(block).toContain("120 mmHg");
+ // The conflicting US/ESC framings stay out of the per-line copy; only
+ // the preamble's neutral "(ACC/AHA)" context mentions them.
+ expect(block).not.toContain("Stage 1");
+ // 118 sits inside the optimal band → "within" placement phrasing.
+ expect(block).toContain("sits inside the general reference band");
+ });
+
+ it("places an out-of-band value as outside", () => {
+ const block = buildReferenceGroundingBlock({
+ metrics: [{ metric: "BLOOD_PRESSURE", value: 165 }],
+ hasDiabetes: false,
+ });
+ expect(block).toContain("sits outside the general reference band");
+ });
+
+ it("opens and closes with a general-guidance, not-a-diagnosis caveat", () => {
+ const block = buildReferenceGroundingBlock({
+ metrics: [{ metric: "BMI", value: 22 }],
+ hasDiabetes: false,
+ })!;
+ expect(block).toContain("general guidance");
+ expect(block).toContain("not a diagnosis");
+ expect(block.toLowerCase()).toContain(
+ "not personal medical advice",
+ );
+ // The closing reminder line is present.
+ expect(block).toMatch(/general guidance, not personal medical advice\.$/);
+ });
+
+ it("grounds glucose against the general non-diabetic band when the opt-in is off", () => {
+ const block = buildReferenceGroundingBlock({
+ metrics: [{ metric: "BLOOD_GLUCOSE", value: 92 }],
+ hasDiabetes: false,
+ })!;
+ expect(block).toContain("general non-diabetic normal");
+ // The non-diabetic fasting normal ceiling is 100 mg/dL.
+ expect(block).toContain("100 mg/dL");
+ expect(block).not.toContain("80–130");
+ expect(block).toContain("sits inside the general reference band");
+ });
+
+ it("grounds glucose against the tighter ADA goal band when diabetes is opted in", () => {
+ const inGoal = buildReferenceGroundingBlock({
+ metrics: [{ metric: "BLOOD_GLUCOSE", value: 110 }],
+ hasDiabetes: true,
+ })!;
+ // The ADA diabetic fasting goal band, framed as a clinician-set goal.
+ expect(inGoal).toContain("80–130 mg/dL");
+ expect(inGoal).toContain("management goal");
+ expect(inGoal).toContain("clinician-set goal");
+ // The line explicitly frames the band as a goal, NOT a screening line.
+ expect(inGoal).toContain("not a screening line");
+ expect(inGoal).toContain("inside the typical diabetes management goal");
+
+ const outOfGoal = buildReferenceGroundingBlock({
+ metrics: [{ metric: "BLOOD_GLUCOSE", value: 200 }],
+ hasDiabetes: true,
+ })!;
+ expect(outOfGoal).toContain("outside the typical diabetes management goal");
+ });
+
+ it("treats a band-less metric (HRV) as baseline-led, never out-of-band", () => {
+ const block = buildReferenceGroundingBlock({
+ metrics: [{ metric: "HEART_RATE_VARIABILITY", value: 45 }],
+ hasDiabetes: false,
+ })!;
+ expect(block).toContain("no fixed population band");
+ expect(block).not.toContain("sits outside");
+ });
+
+ it("is deterministic — identical inputs produce byte-identical output", () => {
+ const input = {
+ metrics: [
+ { metric: "BLOOD_PRESSURE" as const, value: 134 },
+ { metric: "BLOOD_GLUCOSE" as const, value: 105 },
+ { metric: "SLEEP_DURATION" as const, value: 7.5 },
+ ],
+ hasDiabetes: false,
+ };
+ expect(buildReferenceGroundingBlock(input)).toEqual(
+ buildReferenceGroundingBlock(input),
+ );
+ });
+
+ it("caps the number of grounded lines and de-dupes metrics", () => {
+ const block = buildReferenceGroundingBlock({
+ metrics: [
+ { metric: "BMI", value: 22 },
+ { metric: "BMI", value: 23 },
+ ],
+ hasDiabetes: false,
+ })!;
+ // De-duped: only one BMI line.
+ const bmiLines = block.split("\n").filter((l) => l.startsWith("- BMI:"));
+ expect(bmiLines).toHaveLength(1);
+ });
+
+ it("contains NO commercial brand token in any branch", () => {
+ // Exercise both the non-diabetic and diabetic glucose branches plus a
+ // broad metric spread so every code path's text is scanned.
+ const blocks = [
+ buildReferenceGroundingBlock({
+ metrics: [
+ { metric: "BLOOD_PRESSURE", value: 130 },
+ { metric: "BLOOD_GLUCOSE", value: 95 },
+ { metric: "RESTING_HEART_RATE", value: 58 },
+ { metric: "OXYGEN_SATURATION", value: 97 },
+ { metric: "RESPIRATORY_RATE", value: 16 },
+ { metric: "BODY_TEMPERATURE", value: 36.8 },
+ { metric: "BMI", value: 27 },
+ { metric: "STEPS", value: 9000 },
+ { metric: "VISCERAL_FAT", value: 8 },
+ { metric: "SLEEP_DURATION", value: 8 },
+ { metric: "HEART_RATE_VARIABILITY", value: 40 },
+ ],
+ hasDiabetes: false,
+ }),
+ buildReferenceGroundingBlock({
+ metrics: [{ metric: "BLOOD_GLUCOSE", value: 140 }],
+ hasDiabetes: true,
+ }),
+ ];
+ for (const block of blocks) {
+ expect(block).not.toBeNull();
+ for (const brand of BRAND_TOKENS) {
+ expect(block!.toLowerCase()).not.toContain(brand.toLowerCase());
+ }
+ }
+ });
+
+ it("only cites real entries from the medical-citations registry", () => {
+ const block = buildReferenceGroundingBlock({
+ metrics: [
+ { metric: "BLOOD_PRESSURE", value: 120 },
+ { metric: "STEPS", value: 9000 },
+ { metric: "SLEEP_DURATION", value: 8 },
+ ],
+ hasDiabetes: false,
+ })!;
+ // Each cited source label ("NAME (YEAR)") corresponds to a real
+ // registry entry — no fabricated guideline names.
+ const citationNames = Object.values(CITATIONS).map((c) => c.name);
+ // The BP / steps / sleep headline sources must all appear by name.
+ expect(citationNames.some((n) => block.includes(n))).toBe(true);
+ expect(block).toContain("ESH 2023");
+ expect(block).toContain("AASM");
+ });
+});
diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts
index 4000f8ca9..c94c65bb6 100644
--- a/src/lib/ai/coach/__tests__/snapshot.test.ts
+++ b/src/lib/ai/coach/__tests__/snapshot.test.ts
@@ -897,3 +897,96 @@ describe("Coach snapshot — free-text fields wrap through sanitizeForPrompt (L-
expect(hit).toBe(true);
});
});
+
+// v1.18.6 (W7) — the citation-aware reference-range grounding rides through
+// buildCoachSnapshot for the metrics present, gated on the W6 diabetes opt-in.
+describe("buildCoachSnapshot — reference grounding (W7)", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ __resetCoachSnapshotCacheForTests();
+ prismaMock.measurement.findMany.mockResolvedValue([]);
+ prismaMock.moodEntry.findMany.mockResolvedValue([]);
+ prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
+ prismaMock.medication.findMany.mockResolvedValue([]);
+ prismaMock.user.findUnique.mockResolvedValue({ coachPrefsJson: null });
+ resolveModuleMapMock.mockResolvedValue(allModulesEnabled());
+ featuresMock.mockResolvedValue({});
+ });
+
+ it("attaches an ESH-2023-cited grounding block when blood pressure is present", async () => {
+ featuresMock.mockResolvedValue({
+ bloodPressure: { avgSys30: 138, avgDia30: 85, coverage: { count: 4 } },
+ });
+ prismaMock.measurement.findMany.mockResolvedValue([
+ daysAgo(2, 138, "BLOOD_PRESSURE_SYS"),
+ daysAgo(2, 85, "BLOOD_PRESSURE_DIA"),
+ ]);
+
+ const out = await buildCoachSnapshot("user-1", { sources: ["bp"] });
+ expect(out.referenceGrounding).not.toBeNull();
+ expect(out.referenceGrounding).toContain("REFERENCE GROUNDING");
+ expect(out.referenceGrounding).toContain("ESH 2023");
+ // 138 sits in the high-normal band (130–139), two tiers past optimal →
+ // the four-state contract reads that as "outside".
+ expect(out.referenceGrounding).toContain(
+ "sits outside the general reference band",
+ );
+ expect(out.referenceGrounding).toContain("not a diagnosis");
+ });
+
+ it("returns null grounding when no present metric is covered", async () => {
+ const out = await buildCoachSnapshot("user-1");
+ // Empty snapshot — no reference-covered scalar collected.
+ expect(out.referenceGrounding).toBeNull();
+ });
+
+ it("uses the general non-diabetic glucose band when hasDiabetes is unset", async () => {
+ featuresMock.mockResolvedValue({});
+ prismaMock.measurement.findMany.mockResolvedValue([
+ { ...daysAgo(2, 95, "BLOOD_GLUCOSE"), glucoseContext: "FASTING" },
+ ]);
+ prismaMock.user.findUnique.mockResolvedValue({
+ coachPrefsJson: null,
+ hasDiabetes: false,
+ });
+
+ const out = await buildCoachSnapshot("user-1", { sources: ["glucose"] });
+ expect(out.referenceGrounding).toContain("general non-diabetic normal");
+ expect(out.referenceGrounding).not.toContain("management goal");
+ });
+
+ it("uses the tighter ADA goal band when hasDiabetes is opted in", async () => {
+ featuresMock.mockResolvedValue({});
+ prismaMock.measurement.findMany.mockResolvedValue([
+ { ...daysAgo(2, 150, "BLOOD_GLUCOSE"), glucoseContext: "FASTING" },
+ ]);
+ prismaMock.user.findUnique.mockResolvedValue({
+ coachPrefsJson: null,
+ hasDiabetes: true,
+ });
+
+ const out = await buildCoachSnapshot("user-1", { sources: ["glucose"] });
+ expect(out.referenceGrounding).toContain("80–130 mg/dL");
+ expect(out.referenceGrounding).toContain("management goal");
+ // 150 fasting is outside the diabetic goal band.
+ expect(out.referenceGrounding).toContain(
+ "outside the typical diabetes management goal",
+ );
+ });
+
+ it("never emits a commercial brand token in the grounding block", async () => {
+ featuresMock.mockResolvedValue({
+ bloodPressure: { avgSys30: 120, coverage: { count: 4 } },
+ });
+ prismaMock.measurement.findMany.mockResolvedValue([
+ daysAgo(2, 120, "BLOOD_PRESSURE_SYS"),
+ daysAgo(2, 78, "BLOOD_PRESSURE_DIA"),
+ ]);
+ const out = await buildCoachSnapshot("user-1", { sources: ["bp"] });
+ for (const brand of ["Mounjaro", "Withings", "WHOOP", "Oura", "Dexcom"]) {
+ expect(out.referenceGrounding!.toLowerCase()).not.toContain(
+ brand.toLowerCase(),
+ );
+ }
+ });
+});
diff --git a/src/lib/ai/coach/reference-grounding.ts b/src/lib/ai/coach/reference-grounding.ts
new file mode 100644
index 000000000..7c1ff6a94
--- /dev/null
+++ b/src/lib/ai/coach/reference-grounding.ts
@@ -0,0 +1,252 @@
+/**
+ * v1.18.6 — Coach reference-range grounding (W7).
+ *
+ * Turns the population reference bands W5 landed in
+ * `src/lib/reference-ranges.ts` into a compact, citation-aware text block
+ * the Coach prompt can read so a reply is GROUNDED in published guidance
+ * rather than the model's parametric memory of "what's normal". For each
+ * metric the snapshot actually carries, the block states:
+ * - the metric's headline reference band(s), cited to the publishing
+ * body + year (via `referenceLabel` → `medical-citations`), and
+ * - where the user's current value sits against those bands, using the
+ * SAME deterministic `classifyReference()` four-state contract the UI
+ * tiles use — never a recomputed placement.
+ *
+ * GUARDRAILS (firm — a hallucination-QA pass audits this exact text):
+ * - GENERAL guidance only. The block opens AND closes with a caveat that
+ * these are population anchors, not personal medical advice or a
+ * diagnosis. The Coach safety discipline (refusal rules, dose-deferral)
+ * is unchanged — this is read-only context, never an instruction.
+ * - NO commercial brand names. Every string is sourced from
+ * `reference-ranges.ts` (brand-free by construction) + the metric's
+ * natural-language label here. The unit test asserts the rendered
+ * block contains no brand token.
+ * - Blood pressure is labelled to ESH 2023 (the SHIPPED European
+ * default); the ACC/AHA + ESC framings ride only as `conflicts`
+ * CONTEXT, exactly as the band data authored them — never as the
+ * placement.
+ * - GLUCOSE respects the W6 `hasDiabetes` opt-in. When the user has NOT
+ * opted in, glucose is grounded against the GENERAL non-diabetic
+ * fasting bands. When they HAVE, the block states the tighter ADA
+ * glycemic GOAL band (fasting 80–130 mg/dL) as a clinician-set target,
+ * never a screening threshold and never a diagnosis.
+ *
+ * Deterministic + pure: same inputs → byte-identical block, no DB, no LLM,
+ * no per-call randomness. The block is surfaced verbatim in the Coach
+ * user-prompt after the SNAPSHOT so it is fully inspectable.
+ *
+ * Token budget: the block is bounded by the number of metrics present
+ * (one short line each) and a hard `MAX_GROUNDING_METRICS` cap, so even a
+ * 15-metric snapshot stays a few hundred tokens.
+ */
+import {
+ classifyReference,
+ getReferenceRange,
+ referenceLabel,
+ type ReferenceMetric,
+ type ReferencePlacement,
+} from "@/lib/reference-ranges";
+
+/**
+ * One metric the snapshot carries, paired with the user's current
+ * representative value in the metric's reference unit. The caller
+ * (`snapshot.ts`) resolves the value from the same aggregates it already
+ * built, so the grounding reads the SAME number the rest of the snapshot
+ * does.
+ */
+export interface GroundingMetricInput {
+ readonly metric: ReferenceMetric;
+ /**
+ * Current representative value in the metric's reference unit (e.g. the
+ * 30-day mean systolic in mmHg, the fasting-glucose mean in mg/dL). Null
+ * when the snapshot has the metric block but no usable scalar — the line
+ * still cites the band, with an `insufficient` placement.
+ */
+ readonly value: number | null;
+}
+
+export interface ReferenceGroundingInput {
+ readonly metrics: readonly GroundingMetricInput[];
+ /**
+ * The user's explicit, declared diabetes opt-in (`User.hasDiabetes`,
+ * resolved by the W6 path). Selects the tighter ADA glucose GOAL band
+ * for the glucose line ONLY. Never inferred from a reading.
+ */
+ readonly hasDiabetes: boolean;
+}
+
+/**
+ * Hard cap on grounding lines so a maxed-out cluster set cannot balloon
+ * the block. Metrics beyond the cap are dropped silently (the snapshot
+ * still carries their data; only the extra grounding line is shed). The
+ * cap sits above the universal-metric count so a normal account is never
+ * trimmed.
+ */
+const MAX_GROUNDING_METRICS = 14;
+
+/**
+ * Natural-language label for each grounded metric. Deliberately the
+ * user-facing phrasing the Coach prompt already mandates (GROUND RULE 8 —
+ * never the DB enum). No brand names by construction.
+ */
+const METRIC_LABEL: Record = {
+ BLOOD_PRESSURE: "blood pressure (systolic)",
+ PULSE_PRESSURE: "pulse pressure",
+ MEAN_ARTERIAL_PRESSURE: "mean arterial pressure",
+ PULSE_WAVE_VELOCITY: "pulse-wave velocity",
+ RESTING_HEART_RATE: "resting heart rate",
+ HEART_RATE_VARIABILITY: "heart-rate variability",
+ OXYGEN_SATURATION: "oxygen saturation",
+ RESPIRATORY_RATE: "respiratory rate",
+ BODY_TEMPERATURE: "body temperature",
+ BLOOD_GLUCOSE: "fasting glucose",
+ HBA1C: "HbA1c",
+ BMI: "BMI",
+ STEPS: "daily steps",
+ VISCERAL_FAT: "visceral-fat rating",
+ SLEEP_DURATION: "sleep duration",
+};
+
+/**
+ * The ADA diabetic glycemic GOAL fasting band (mg/dL), kept in lock-step
+ * with `src/lib/targets/glucose-targets.ts` `DIABETIC_GOAL_BANDS.FASTING`.
+ * Only used to phrase the glucose grounding line when `hasDiabetes` is set;
+ * the placement still defers to the user's clinician, never to a band edge.
+ */
+const ADA_DIABETIC_FASTING_GOAL = { low: 80, high: 130 } as const;
+
+/** Map a four-state placement to a short, general-guidance phrase. */
+function placementPhrase(placement: ReferencePlacement): string {
+ switch (placement) {
+ case "within":
+ return "sits inside the general reference band";
+ case "slightly-outside":
+ return "sits just outside the general reference band";
+ case "outside":
+ return "sits outside the general reference band";
+ case "insufficient":
+ // No scalar, or a metric with no fixed population band (e.g. HRV) —
+ // the band is cited for education, the placement is left to the
+ // user's own baseline.
+ return "has no fixed population band — your own trend leads";
+ }
+}
+
+/**
+ * Render the half-open band as a compact "low–high unit" descriptor for the
+ * metric's headline (first/normal) band. Open-ended bounds render with a
+ * leading/trailing ≤ / ≥.
+ */
+function headlineBandText(metric: ReferenceMetric): string | null {
+ const range = getReferenceRange(metric);
+ if (!range || range.bands.length === 0) return null;
+ const band = range.bands[0];
+ const unit = range.unit;
+ if (band.low != null && band.high != null) {
+ return `${band.low}–${band.high} ${unit}`;
+ }
+ if (band.high != null) return `≤${band.high} ${unit}`;
+ if (band.low != null) return `≥${band.low} ${unit}`;
+ return null;
+}
+
+/**
+ * Build the glucose grounding line. Branches on the W6 diabetes opt-in:
+ * general non-diabetic fasting band by default, the tighter ADA glycemic
+ * GOAL band when the user declared diabetes. The diabetic branch frames the
+ * band as a clinician-set GOAL, never a screening threshold.
+ */
+function glucoseLine(value: number | null, hasDiabetes: boolean): string {
+ const label = METRIC_LABEL.BLOOD_GLUCOSE;
+ if (hasDiabetes) {
+ const { low, high } = ADA_DIABETIC_FASTING_GOAL;
+ // Placement against the diabetic GOAL band, framed as a target the
+ // user's clinician individualises — never a diagnostic call.
+ let where: string;
+ if (value == null || !Number.isFinite(value)) {
+ where = "no usable value this window";
+ } else if (value >= low && value <= high) {
+ where = "is inside the typical diabetes management goal";
+ } else {
+ where = "is outside the typical diabetes management goal";
+ }
+ return `- ${label}: a common diabetes management goal is ${low}–${high} mg/dL fasting (${referenceLabel(
+ "BLOOD_GLUCOSE",
+ )} — clinician-set goal, individualised, not a screening line). Yours ${where}.`;
+ }
+ const band = headlineBandText("BLOOD_GLUCOSE");
+ const placement = classifyReference("BLOOD_GLUCOSE", value);
+ return `- ${label}: general non-diabetic normal is ${band} (${referenceLabel(
+ "BLOOD_GLUCOSE",
+ )}). Yours ${placementPhrase(placement)}.`;
+}
+
+/** Build one standard (non-glucose) grounding line for a metric. */
+function standardLine(metric: ReferenceMetric, value: number | null): string {
+ const label = METRIC_LABEL[metric];
+ const band = headlineBandText(metric);
+ const placement = classifyReference(metric, value);
+ const cite = referenceLabel(metric);
+ // Blood pressure cites ESH 2023 by construction (the reference-range
+ // headline band is sourced to ESH_2023_HYPERTENSION); the conflicts
+ // (ACC/AHA, ESC) stay out of the per-line copy and live in the block
+ // preamble's "guidelines disagree" note so the Coach never mislabels the
+ // shipped European line.
+ if (band) {
+ return `- ${label}: general reference ${band} (${cite}). Yours ${placementPhrase(
+ placement,
+ )}.`;
+ }
+ // No fixed band (HRV) — cite the source, defer wholly to the baseline.
+ return `- ${label}: ${placementPhrase(placement)} (${cite}).`;
+}
+
+/**
+ * Build the Coach reference-grounding block, or null when no metric in the
+ * input is covered by the reference backbone (so the prompt carries no
+ * empty section). The returned string is plain text with a stable shape:
+ * a header, a general-guidance preamble, one line per metric, and a
+ * closing caveat. The route appends it verbatim after the SNAPSHOT.
+ */
+export function buildReferenceGroundingBlock(
+ input: ReferenceGroundingInput,
+): string | null {
+ // De-dupe + keep input order; only metrics the backbone covers.
+ const seen = new Set();
+ const ordered: GroundingMetricInput[] = [];
+ for (const m of input.metrics) {
+ if (seen.has(m.metric)) continue;
+ if (!getReferenceRange(m.metric)) continue;
+ seen.add(m.metric);
+ ordered.push(m);
+ if (ordered.length >= MAX_GROUNDING_METRICS) break;
+ }
+ if (ordered.length === 0) return null;
+
+ const lines = ordered.map((m) =>
+ m.metric === "BLOOD_GLUCOSE"
+ ? glucoseLine(m.value, input.hasDiabetes)
+ : standardLine(m.metric, m.value),
+ );
+
+ // The preamble + closing caveat carry the firm general-guidance framing.
+ // Blood-pressure guideline disagreement is surfaced once here (context),
+ // never as a per-line placement, so the Coach reads ESH 2023 as the
+ // shipped line.
+ const header = "REFERENCE GROUNDING (general guidance — not a diagnosis)";
+ const preamble = [
+ "These are published POPULATION reference bands for context only — they",
+ "are not personal medical advice, not a diagnosis, and not a target set",
+ "for this user. The user's own baseline always leads the read. Cite a",
+ "band only to orient a reply (\"the general reference range is …\"); never",
+ "tell the user they \"have\" or \"don't have\" a condition from a band.",
+ "Blood pressure follows the European ESH 2023 line; US (ACC/AHA) and",
+ "ESC framings differ and are context, not the call. This block does not",
+ "change any safety rule above — keep deferring dose / diagnosis / drug-",
+ "level questions to the user's clinician exactly as instructed.",
+ ].join("\n");
+ const closing =
+ "Reminder: ranges are general guidance, not personal medical advice.";
+
+ return `${header}\n${preamble}\n\n${lines.join("\n")}\n\n${closing}`;
+}
diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts
index f8e82811a..339482f58 100644
--- a/src/lib/ai/coach/snapshot.ts
+++ b/src/lib/ai/coach/snapshot.ts
@@ -49,6 +49,11 @@ import { buildCoachMemoryBlock } from "./memory-snapshot";
import { buildTrajectorySnapshotBlock } from "./trajectory-snapshot";
import { buildCycleSnapshotBlock } from "./cycle-snapshot";
import { buildIllnessSnapshotBlock } from "./illness-snapshot";
+import {
+ buildReferenceGroundingBlock,
+ type GroundingMetricInput,
+} from "./reference-grounding";
+import type { ReferenceMetric } from "@/lib/reference-ranges";
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate";
import {
@@ -81,6 +86,15 @@ export interface CoachSnapshotResult {
* the model could see.
*/
provenance: CoachProvenance;
+ /**
+ * v1.18.6 (W7) — citation-aware reference-range grounding for the
+ * metrics present in this snapshot, or null when none is covered by the
+ * reference backbone. The Coach route appends it verbatim after the
+ * SNAPSHOT so the model reads published population bands + the user's
+ * placement (general guidance, never a diagnosis). Built deterministically
+ * from `src/lib/reference-ranges.ts`; carries no commercial brand name.
+ */
+ referenceGrounding: string | null;
}
/**
@@ -565,6 +579,11 @@ async function buildCoachSnapshotImpl(
// display unit so the Coach reads the same number every other surface
// shows. Read it on this existing prefs hop (no extra round-trip).
glucoseUnit: true,
+ // v1.18.6 (W7) — the explicit, user-declared diabetes opt-in. Selects
+ // the tighter ADA glycemic GOAL band for the glucose reference-grounding
+ // line only; never inferred from a reading, never a diagnosis. Read on
+ // this existing prefs hop (no extra round-trip).
+ hasDiabetes: true,
},
});
const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson);
@@ -652,6 +671,37 @@ async function buildCoachSnapshotImpl(
const metrics = new Set();
const counts: NonNullable = {};
+ // v1.18.6 (W7) — representative scalar per reference-covered metric, in the
+ // metric's reference unit, collected as the blocks below build. Feeds the
+ // citation-aware grounding block (population band + the user's placement).
+ // Each entry reads the SAME number the snapshot already surfaces — no
+ // independent recompute — so the grounding can never cite a value the
+ // snapshot doesn't carry. A metric with a block but no clean scalar is left
+ // unset (the grounding line still cites its band with an insufficient
+ // placement only if it is added with a null value; we omit such metrics).
+ const groundingValues = new Map();
+ // Additive `MeasurementType` → reference metric, for the value-block loop.
+ const TYPE_TO_REFERENCE_METRIC: Record = {
+ RESTING_HEART_RATE: "RESTING_HEART_RATE",
+ OXYGEN_SATURATION: "OXYGEN_SATURATION",
+ RESPIRATORY_RATE: "RESPIRATORY_RATE",
+ PULSE_WAVE_VELOCITY: "PULSE_WAVE_VELOCITY",
+ BODY_TEMPERATURE: "BODY_TEMPERATURE",
+ BODY_MASS_INDEX: "BMI",
+ VISCERAL_FAT: "VISCERAL_FAT",
+ ACTIVITY_STEPS: "STEPS",
+ };
+ /** Mean of the most recent N daily-value rows (already user-tz day means). */
+ const recentRowsMean = (
+ rows: DailyValueRow[],
+ take = DAILY_TIMELINE_DAYS,
+ ): number | null => {
+ if (rows.length === 0) return null;
+ const slice = rows.slice(-take);
+ const sum = slice.reduce((s, r) => s + r.value, 0);
+ return sum / slice.length;
+ };
+
// v1.7.0 — block registry. Maps each emitted snapshot top-level key
// to the cluster it belongs to so the soft-cap degradation pass
// (below) can walk blocks in reverse cluster-priority order and shed
@@ -1119,6 +1169,10 @@ async function buildCoachSnapshotImpl(
windows.add("last90days");
counts.bp = features.bloodPressure.coverage?.count ?? undefined;
registerBlock("bloodPressure", "bp");
+ // W7 grounding: systolic 30-day mean against the ESH 2023 office bands.
+ const sys30 =
+ features.bloodPressure.avgSys30 ?? features.bloodPressure.allTimeAvgSys;
+ if (sys30 != null) groundingValues.set("BLOOD_PRESSURE", sys30);
}
if (wantsWeight && features.weight) {
const rows = byType("WEIGHT");
@@ -1137,6 +1191,13 @@ async function buildCoachSnapshotImpl(
windows.add("last30days");
counts.weight = features.weight.coverage?.count ?? undefined;
registerBlock("weight", "weight");
+ // W7 grounding: weight has no population band, but the derived BMI does.
+ // Use the features-computed BMI (same value the weight tile shows) so the
+ // grounding reads the WHO band. A dedicated BODY_MASS_INDEX block (below)
+ // wins if the user also syncs a measured BMI series.
+ if (features.weight.bmi != null) {
+ groundingValues.set("BMI", features.weight.bmi);
+ }
}
if (wantsPulse && features.pulse) {
const rows = byType("PULSE");
@@ -1412,6 +1473,14 @@ async function buildCoachSnapshotImpl(
metrics.add(block.metric);
counts[block.metric] = rows.length;
registerBlock(block.snapshotKey, block.source);
+ // W7 grounding: when this additive series maps to a reference metric,
+ // record the recent daily mean (same per-day means the timeline shows).
+ const refMetric = TYPE_TO_REFERENCE_METRIC[block.type];
+ if (refMetric) {
+ const recentRows = buildDailyValueRows(rows, recentCutoff, userTz);
+ const mean = recentRowsMean(recentRows);
+ if (mean != null) groundingValues.set(refMetric, mean);
+ }
}
// ── v1.7.0 sleep block (with optional per-stage enrichment) ───────
@@ -1480,6 +1549,19 @@ async function buildCoachSnapshotImpl(
metrics.add("sleep");
counts.sleep = sleepRows.length;
registerBlock("sleep", "sleep");
+ // W7 grounding: recent nightly asleep duration in HOURS against the
+ // AASM 7–9 h band (the reference unit is hours; the snapshot stores
+ // minutes). Mean over the recent nights the block already reconstructed.
+ if (recentNights.length > 0) {
+ const meanMin =
+ recentNights.reduce(
+ (s, n) => s + (typeof n.minutes === "number" ? n.minutes : 0),
+ 0,
+ ) / recentNights.length;
+ if (meanMin > 0) {
+ groundingValues.set("SLEEP_DURATION", meanMin / 60);
+ }
+ }
}
}
@@ -1653,6 +1735,22 @@ async function buildCoachSnapshotImpl(
metrics.add("glucose");
counts.glucose = glucoseRows.length;
registerBlock("glucose", "glucose");
+ // W7 grounding: fasting glucose mean in RAW mg/dL (the reference band's
+ // unit), independent of the user's mmol/L display preference. The
+ // grounding line's band selection respects the W6 `hasDiabetes` opt-in;
+ // here we only feed the representative fasting value. Fall back to the
+ // overall mean when no row is tagged FASTING so the band is still cited.
+ const fastingRows = glucoseRows.filter(
+ (r) => String(r.glucoseContext).toUpperCase() === "FASTING",
+ );
+ const glucoseScalarRows =
+ fastingRows.length > 0 ? fastingRows : glucoseRows;
+ const glucoseMeanMgdl =
+ glucoseScalarRows.reduce((s, r) => s + r.value, 0) /
+ glucoseScalarRows.length;
+ if (Number.isFinite(glucoseMeanMgdl)) {
+ groundingValues.set("BLOOD_GLUCOSE", glucoseMeanMgdl);
+ }
}
}
@@ -1894,6 +1992,29 @@ async function buildCoachSnapshotImpl(
// `coach.snapshot.truncated` annotation when it sheds anything.
degradeToBudget(compactSnapshot, blockClusters);
+ // v1.18.6 (W7) — build the citation-aware reference-grounding block from the
+ // representative scalars collected above. Deterministic + pure; the route
+ // appends it verbatim after the SNAPSHOT. Null when no present metric is
+ // covered by the reference backbone. Insertion order follows the block-build
+ // order (BP first), giving a stable, inspectable block for the
+ // hallucination-QA pass.
+ const groundingMetrics: GroundingMetricInput[] = Array.from(
+ groundingValues.entries(),
+ ).map(([metric, value]) => ({ metric, value }));
+ const referenceGrounding = buildReferenceGroundingBlock({
+ metrics: groundingMetrics,
+ hasDiabetes: prefsRow?.hasDiabetes ?? false,
+ });
+ if (referenceGrounding) {
+ annotate({
+ action: { name: "coach.grounding.attached" },
+ meta: {
+ metrics: groundingMetrics.map((m) => m.metric).sort(),
+ hasDiabetes: prefsRow?.hasDiabetes ?? false,
+ },
+ });
+ }
+
return {
snapshotJson: JSON.stringify(compactSnapshot, null, 2),
provenance: {
@@ -1901,6 +2022,7 @@ async function buildCoachSnapshotImpl(
metrics: Array.from(metrics),
counts: Object.keys(counts).length > 0 ? counts : undefined,
},
+ referenceGrounding,
};
}
diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts
index 608aaf262..0c94d276b 100644
--- a/src/lib/ai/coach/system-prompt.ts
+++ b/src/lib/ai/coach/system-prompt.ts
@@ -189,6 +189,22 @@ GROUND RULES
day, decline and say the forecast is a descriptive estimate, not
a contraceptive method. When NO "cycle" block is present, do not
infer or guess a phase.
+13. The user prompt MAY carry a "REFERENCE GROUNDING" block after the
+ SNAPSHOT. It lists published POPULATION reference bands for the
+ metrics present plus where the user's value sits, each cited to its
+ source (e.g. ESH 2023 for blood pressure). Use it ONLY as general
+ orientation — "the general reference range is …", "that sits a little
+ above the usual band". It is NOT personal medical advice, NOT a
+ diagnosis, and NOT a target for this user; the user's own baseline
+ still leads. Never tell the user they "have" or "don't have" a
+ condition from a band, never convert a band into a risk score, and
+ never let the grounding soften the safety rules above — keep deferring
+ dose / diagnosis / drug-level questions to their clinician. Blood
+ pressure reads on the European ESH 2023 line; the US (ACC/AHA) and
+ ESC framings differ and are context only. If the user has declared
+ diabetes, the glucose line states a clinician-set management GOAL, not
+ a screening threshold. When no REFERENCE GROUNDING block is present,
+ do not invent a band — pivot to the user's own trend.
DAY-LEVEL READINGS — USE THE TIMELINE
@@ -580,6 +596,25 @@ GRUNDREGELN
dass die Prognose eine beschreibende Schätzung ist, keine
Verhütungsmethode. Wenn KEIN "cycle"-Block vorhanden ist, leite
oder rate keine Phase ab.
+13. Der User-Prompt kann nach dem SNAPSHOT einen "REFERENCE
+ GROUNDING"-Block mitbringen. Er nennt publizierte POPULATIONS-
+ Referenzbereiche für die vorhandenen Metriken plus die Einordnung des
+ Nutzerwerts, jeweils mit Quelle zitiert (z. B. ESH 2023 für den
+ Blutdruck). Nutze ihn AUSSCHLIESSLICH zur groben Orientierung — "der
+ allgemeine Referenzbereich liegt bei …", "das liegt etwas über dem
+ üblichen Bereich". Er ist KEINE persönliche medizinische Beratung,
+ KEINE Diagnose und KEIN Zielwert für diesen Nutzer; die eigene
+ Ausgangslage des Nutzers führt weiterhin. Sag dem Nutzer nie, er
+ "habe" oder "habe nicht" eine Erkrankung aus einem Bereich, mach aus
+ einem Bereich nie einen Risiko-Score, und lass das Grounding nie die
+ Sicherheitsregeln oben aufweichen — verweise Dosis-/Diagnose-/
+ Wirkstoffspiegel-Fragen weiter an die behandelnde Ärztin. Der
+ Blutdruck liest sich auf der europäischen ESH-2023-Linie; die US-
+ (ACC/AHA) und ESC-Einordnungen weichen ab und sind nur Kontext. Hat
+ der Nutzer Diabetes angegeben, nennt die Glukose-Zeile ein
+ klinisch gesetztes Management-ZIEL, keinen Screening-Schwellwert.
+ Ist kein REFERENCE-GROUNDING-Block vorhanden, erfinde keinen Bereich
+ — wechsle zum eigenen Trend des Nutzers.
TAGES-LEVEL-MESSWERTE — NUTZE DIE TIMELINE
From 9265ab89c630f9a59c281db69aac20869e640e9b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 20:41:29 +0200
Subject: [PATCH 42/71] fix(reference): resolve the normal band by marker, not
index 0
The reference-range classifier assumed bands[0] was always the
normal/target band, so any metric whose first band is abnormal was
placed wrong: a healthy BMI 22 read as just outside the reference and
an underweight BMI 17 read as inside; pulse pressure had the same flaw
(Narrow is its first band). The same classifier drives the in-app
four-state explainer, so both surfaces were affected.
Mark the population-normal band explicitly with a `normal: true` flag
on every metric, add `normalBandIndex`, and place values by distance
from the marked band rather than from index 0. `headlineBandText` now
renders the marked band, so BMI grounds against 18.5-24.9 kg/m2.
Exclude daily steps from the Coach grounding: it is fed the intra-day
mean of the step rows, which averages a multi-row day down and can
misplace it below the 8,000 floor. Grounding it on a per-day sum is the
correct fix and is left as a follow-up; until then drop the line rather
than ship a wrong placement.
Extend the classifier and grounding tests for BMI 17/22/27, a pulse
pressure case, the normal-band-not-at-index-0 contract, and that steps
is not grounded.
---
src/lib/__tests__/reference-ranges.test.ts | 37 ++++++++
.../__tests__/reference-grounding.test.ts | 65 ++++++++++++++
src/lib/ai/coach/reference-grounding.ts | 17 +++-
src/lib/reference-ranges.ts | 89 ++++++++++++-------
4 files changed, 175 insertions(+), 33 deletions(-)
diff --git a/src/lib/__tests__/reference-ranges.test.ts b/src/lib/__tests__/reference-ranges.test.ts
index 910e80e05..730739d9c 100644
--- a/src/lib/__tests__/reference-ranges.test.ts
+++ b/src/lib/__tests__/reference-ranges.test.ts
@@ -7,6 +7,7 @@ import {
classifyReference,
getReferenceRange,
isReferenceMetric,
+ normalBandIndex,
referenceCitation,
referenceLabel,
type ReferenceMetric,
@@ -54,6 +55,42 @@ describe("reference-ranges backbone", () => {
expect(classifyReference("OXYGEN_SATURATION", 98)).toBe("within");
});
+ it("every banded metric marks exactly one normal band", () => {
+ for (const metric of REFERENCE_METRICS) {
+ const bands = REFERENCE_RANGES[metric].bands;
+ if (bands.length === 0) continue;
+ const marked = bands.filter((b) => b.normal === true);
+ expect(marked, `${metric} normal-band count`).toHaveLength(1);
+ // `normalBandIndex` points at that marked band.
+ expect(bands[normalBandIndex(metric)].normal, `${metric} index`).toBe(true);
+ }
+ });
+
+ it("resolves the normal band by marker, not by index 0", () => {
+ // BMI authors Underweight FIRST; the normal band is the 18.5–24.9 entry.
+ expect(REFERENCE_RANGES.BMI.bands[0].label).toBe("Underweight");
+ expect(REFERENCE_RANGES.BMI.bands[normalBandIndex("BMI")].label).toBe("Normal");
+ // BMI 22 is healthy → within (the index-0 bug reported it slightly-outside).
+ expect(classifyReference("BMI", 22)).toBe("within");
+ // BMI 17 is underweight → one band below normal → slightly-outside
+ // (the index-0 bug reported it as within).
+ expect(classifyReference("BMI", 17)).toBe("slightly-outside");
+ // BMI 27 overweight (one above) → slightly-outside; 35 obesity (two
+ // above) → outside.
+ expect(classifyReference("BMI", 27)).toBe("slightly-outside");
+ expect(classifyReference("BMI", 35)).toBe("outside");
+ });
+
+ it("classifies pulse pressure around its interior normal band", () => {
+ // Narrow is the FIRST band; the normal band is "Typical at rest".
+ expect(REFERENCE_RANGES.PULSE_PRESSURE.bands[0].label).toBe("Narrow");
+ expect(classifyReference("PULSE_PRESSURE", 40)).toBe("within");
+ // Narrow (≤25) is one band below normal → slightly-outside.
+ expect(classifyReference("PULSE_PRESSURE", 20)).toBe("slightly-outside");
+ // Wide (≥60) is one band above normal → slightly-outside.
+ expect(classifyReference("PULSE_PRESSURE", 75)).toBe("slightly-outside");
+ });
+
it("returns insufficient for absent values and band-less metrics", () => {
expect(classifyReference("RESTING_HEART_RATE", null)).toBe("insufficient");
expect(classifyReference("RESTING_HEART_RATE", Number.NaN)).toBe("insufficient");
diff --git a/src/lib/ai/coach/__tests__/reference-grounding.test.ts b/src/lib/ai/coach/__tests__/reference-grounding.test.ts
index c04540a83..d010e8b37 100644
--- a/src/lib/ai/coach/__tests__/reference-grounding.test.ts
+++ b/src/lib/ai/coach/__tests__/reference-grounding.test.ts
@@ -113,6 +113,71 @@ describe("buildReferenceGroundingBlock", () => {
expect(outOfGoal).toContain("outside the typical diabetes management goal");
});
+ it("grounds BMI against the WHO normal band, not the first (Underweight) band", () => {
+ // The headline band rendered must be the 18.5–24.9 normal range — the
+ // index-0 bug surfaced the Underweight "≤18.5" ceiling instead.
+ const normal = buildReferenceGroundingBlock({
+ metrics: [{ metric: "BMI", value: 22 }],
+ hasDiabetes: false,
+ })!;
+ const bmiLine = normal.split("\n").find((l) => l.startsWith("- BMI:"))!;
+ expect(bmiLine).toContain("18.5–24.9 kg/m²");
+ expect(bmiLine).not.toContain("≤18.5");
+ // A healthy 22 sits INSIDE the band (was wrongly "just outside").
+ expect(bmiLine).toContain("sits inside the general reference band");
+
+ // Underweight 17 sits just outside (was wrongly "inside").
+ const under = buildReferenceGroundingBlock({
+ metrics: [{ metric: "BMI", value: 17 }],
+ hasDiabetes: false,
+ })!;
+ expect(under.split("\n").find((l) => l.startsWith("- BMI:"))).toContain(
+ "sits just outside the general reference band",
+ );
+
+ // Overweight 27 sits just outside.
+ const over = buildReferenceGroundingBlock({
+ metrics: [{ metric: "BMI", value: 27 }],
+ hasDiabetes: false,
+ })!;
+ expect(over.split("\n").find((l) => l.startsWith("- BMI:"))).toContain(
+ "sits just outside the general reference band",
+ );
+ });
+
+ it("grounds pulse pressure against its interior normal band", () => {
+ const block = buildReferenceGroundingBlock({
+ metrics: [{ metric: "PULSE_PRESSURE", value: 40 }],
+ hasDiabetes: false,
+ })!;
+ const line = block.split("\n").find((l) => l.startsWith("- pulse pressure:"))!;
+ // The normal "Typical at rest" band (25–60), not the first "Narrow" band.
+ expect(line).toContain("25–60 mmHg");
+ expect(line).toContain("sits inside the general reference band");
+ });
+
+ it("does not ground steps (mean-aggregation misplacement; follow-up)", () => {
+ const block = buildReferenceGroundingBlock({
+ metrics: [
+ { metric: "STEPS", value: 9000 },
+ { metric: "BMI", value: 22 },
+ ],
+ hasDiabetes: false,
+ })!;
+ // STEPS is dropped; the BMI line still renders.
+ expect(block).not.toContain("daily steps");
+ expect(block.split("\n").some((l) => l.startsWith("- BMI:"))).toBe(true);
+ });
+
+ it("returns null when steps is the only metric (it is not grounded)", () => {
+ expect(
+ buildReferenceGroundingBlock({
+ metrics: [{ metric: "STEPS", value: 9000 }],
+ hasDiabetes: false,
+ }),
+ ).toBeNull();
+ });
+
it("treats a band-less metric (HRV) as baseline-led, never out-of-band", () => {
const block = buildReferenceGroundingBlock({
metrics: [{ metric: "HEART_RATE_VARIABILITY", value: 45 }],
diff --git a/src/lib/ai/coach/reference-grounding.ts b/src/lib/ai/coach/reference-grounding.ts
index 7c1ff6a94..9263fa7b6 100644
--- a/src/lib/ai/coach/reference-grounding.ts
+++ b/src/lib/ai/coach/reference-grounding.ts
@@ -42,6 +42,7 @@
import {
classifyReference,
getReferenceRange,
+ normalBandIndex,
referenceLabel,
type ReferenceMetric,
type ReferencePlacement,
@@ -134,13 +135,16 @@ function placementPhrase(placement: ReferencePlacement): string {
/**
* Render the half-open band as a compact "low–high unit" descriptor for the
- * metric's headline (first/normal) band. Open-ended bounds render with a
- * leading/trailing ≤ / ≥.
+ * metric's NORMAL band — resolved by the `normal` marker, NOT by index 0
+ * (some metrics author an abnormal band first, e.g. BMI Underweight or
+ * pulse-pressure Narrow). Open-ended bounds render with a leading/trailing
+ * ≤ / ≥.
*/
function headlineBandText(metric: ReferenceMetric): string | null {
const range = getReferenceRange(metric);
if (!range || range.bands.length === 0) return null;
- const band = range.bands[0];
+ const idx = normalBandIndex(metric);
+ const band = range.bands[idx];
const unit = range.unit;
if (band.low != null && band.high != null) {
return `${band.low}–${band.high} ${unit}`;
@@ -217,6 +221,13 @@ export function buildReferenceGroundingBlock(
for (const m of input.metrics) {
if (seen.has(m.metric)) continue;
if (!getReferenceRange(m.metric)) continue;
+ // STEPS is excluded from grounding for now: the caller feeds the
+ // intra-day MEAN of the ACTIVITY_STEPS rows, so a day logged across
+ // several rows is averaged DOWN and can be misclassified below the
+ // 8,000-step floor. Grounding it on a sum-per-day aggregate is the
+ // correct fix and is tracked as a follow-up; until then we drop the
+ // line rather than ship a wrong placement.
+ if (m.metric === "STEPS") continue;
seen.add(m.metric);
ordered.push(m);
if (ordered.length >= MAX_GROUNDING_METRICS) break;
diff --git a/src/lib/reference-ranges.ts b/src/lib/reference-ranges.ts
index 88e8c0581..288507a45 100644
--- a/src/lib/reference-ranges.ts
+++ b/src/lib/reference-ranges.ts
@@ -39,6 +39,16 @@ export interface ReferenceBand {
readonly high?: number;
/** The body + year that publishes this band. */
readonly citation: CitationId;
+ /**
+ * Marks THE population-normal / target band — the placement
+ * `classifyReference` treats as "within" and `headlineBandText` renders
+ * as the headline. Exactly one band per metric (with at least one band)
+ * carries this. Bands are NOT assumed normal by index: a metric whose
+ * first band is abnormal (BMI → Underweight first; pulse pressure →
+ * Narrow first) marks the correct interior band instead. A test asserts
+ * the one-marker-per-banded-metric invariant.
+ */
+ readonly normal?: boolean;
}
/**
@@ -55,7 +65,12 @@ export interface ReferenceConflict {
export interface ReferenceRange {
/** Canonical display unit (matches the metric tile + registry). */
readonly unit: string;
- /** Ordered population bands; the first is the "good"/normal placement. */
+ /**
+ * Ordered population bands. The "good"/normal placement is the band
+ * flagged `normal: true` — NOT necessarily the first entry. Some metrics
+ * (BMI, pulse pressure) author an abnormal band first, so consumers must
+ * resolve the normal band by the marker, never by index.
+ */
readonly bands: readonly ReferenceBand[];
/** The body the headline band is sourced to (drives the cited sentence). */
readonly referenceId: CitationId;
@@ -95,7 +110,7 @@ export const REFERENCE_RANGES: Record = {
unit: "mmHg",
bands: [
// ESH grade bands (office, mmHg, systolic anchors shown).
- { label: "Optimal", high: 120, citation: "ESH_2023_HYPERTENSION" },
+ { label: "Optimal", high: 120, citation: "ESH_2023_HYPERTENSION", normal: true },
{ label: "Normal", low: 120, high: 129, citation: "ESH_2023_HYPERTENSION" },
{ label: "High-normal", low: 130, high: 139, citation: "ESH_2023_HYPERTENSION" },
{ label: "Hypertension", low: 140, citation: "ESH_2023_HYPERTENSION" },
@@ -120,7 +135,7 @@ export const REFERENCE_RANGES: Record = {
unit: "mmHg",
bands: [
{ label: "Narrow", high: 25, citation: "STATPEARLS_PULSE_PRESSURE" },
- { label: "Typical at rest", low: 25, high: 60, citation: "STATPEARLS_PULSE_PRESSURE" },
+ { label: "Typical at rest", low: 25, high: 60, citation: "STATPEARLS_PULSE_PRESSURE", normal: true },
{ label: "Wide", low: 60, citation: "STATPEARLS_PULSE_PRESSURE" },
],
referenceId: "STATPEARLS_PULSE_PRESSURE",
@@ -132,7 +147,7 @@ export const REFERENCE_RANGES: Record = {
MEAN_ARTERIAL_PRESSURE: {
unit: "mmHg",
bands: [
- { label: "Resting band", low: 70, high: 100, citation: "STATPEARLS_MAP" },
+ { label: "Resting band", low: 70, high: 100, citation: "STATPEARLS_MAP", normal: true },
],
referenceId: "STATPEARLS_MAP",
guidanceCaveat:
@@ -142,7 +157,7 @@ export const REFERENCE_RANGES: Record = {
PULSE_WAVE_VELOCITY: {
unit: "m/s",
bands: [
- { label: "Reference", high: 10, citation: "ESC_ESH_2018_PWV" },
+ { label: "Reference", high: 10, citation: "ESC_ESH_2018_PWV", normal: true },
{ label: "Elevated stiffness", low: 10, citation: "ESC_ESH_2018_PWV" },
],
referenceId: "ESC_ESH_2018_PWV",
@@ -151,14 +166,14 @@ export const REFERENCE_RANGES: Record = {
},
// ── Resting heart rate — uses RESTING_HEART_RATE, never workout pulse. ──
- // Bands are authored normal-first so `classifyReference` treats the
- // population-normal band as the "within" placement. The athletic band
+ // The 60–100 band carries `normal: true`, so `classifyReference` treats it
+ // as the "within" placement regardless of band order. The athletic band
// (40–60) is benign for a trained heart but sits below the standard
// normal range — the caveat copy carries that nuance.
RESTING_HEART_RATE: {
unit: "bpm",
bands: [
- { label: "Normal adult resting", low: 60, high: 100, citation: "AHA_2024_RHR" },
+ { label: "Normal adult resting", low: 60, high: 100, citation: "AHA_2024_RHR", normal: true },
{ label: "Athletic (benign)", low: 40, high: 60, citation: "AHA_2024_RHR" },
],
referenceId: "AHA_2024_RHR",
@@ -178,7 +193,7 @@ export const REFERENCE_RANGES: Record = {
OXYGEN_SATURATION: {
unit: "%",
bands: [
- { label: "Healthy room air", low: 95, high: 100, citation: "STATPEARLS_PULSE_OX" },
+ { label: "Healthy room air", low: 95, high: 100, citation: "STATPEARLS_PULSE_OX", normal: true },
{ label: "Mild hypoxaemia", low: 91, high: 94, citation: "STATPEARLS_PULSE_OX" },
],
referenceId: "STATPEARLS_PULSE_OX",
@@ -189,7 +204,7 @@ export const REFERENCE_RANGES: Record = {
RESPIRATORY_RATE: {
unit: "breaths/min",
bands: [
- { label: "Normal resting adult", low: 12, high: 20, citation: "ALA_RESPIRATORY_RATE" },
+ { label: "Normal resting adult", low: 12, high: 20, citation: "ALA_RESPIRATORY_RATE", normal: true },
{ label: "Worth attention", low: 22, citation: "RCP_2017_NEWS2" },
],
referenceId: "ALA_RESPIRATORY_RATE",
@@ -201,7 +216,7 @@ export const REFERENCE_RANGES: Record = {
BODY_TEMPERATURE: {
unit: "°C",
bands: [
- { label: "Normal oral range", low: 35.7, high: 37.4, citation: "JGIM_2019_TEMPERATURE" },
+ { label: "Normal oral range", low: 35.7, high: 37.4, citation: "JGIM_2019_TEMPERATURE", normal: true },
{ label: "Fever", low: 38.0, citation: "JGIM_2019_TEMPERATURE" },
],
referenceId: "JGIM_2019_TEMPERATURE",
@@ -213,7 +228,7 @@ export const REFERENCE_RANGES: Record = {
BLOOD_GLUCOSE: {
unit: "mg/dL",
bands: [
- { label: "Normal fasting", high: 100, citation: "ADA_2024_GLYCEMIC" },
+ { label: "Normal fasting", high: 100, citation: "ADA_2024_GLYCEMIC", normal: true },
{ label: "Prediabetes (fasting)", low: 100, high: 125, citation: "ADA_2024_GLYCEMIC" },
{ label: "Diabetes range (fasting)", low: 126, citation: "ADA_2024_GLYCEMIC" },
],
@@ -231,7 +246,7 @@ export const REFERENCE_RANGES: Record = {
HBA1C: {
unit: "%",
bands: [
- { label: "Normal", high: 5.7, citation: "ADA_2024_GLYCEMIC" },
+ { label: "Normal", high: 5.7, citation: "ADA_2024_GLYCEMIC", normal: true },
{ label: "Prediabetes", low: 5.7, high: 6.4, citation: "ADA_2024_GLYCEMIC" },
{ label: "Diabetes", low: 6.5, citation: "ADA_2024_GLYCEMIC" },
],
@@ -244,7 +259,7 @@ export const REFERENCE_RANGES: Record = {
unit: "kg/m²",
bands: [
{ label: "Underweight", high: 18.5, citation: "WHO_2020_PA" },
- { label: "Normal", low: 18.5, high: 24.9, citation: "WHO_2020_PA" },
+ { label: "Normal", low: 18.5, high: 24.9, citation: "WHO_2020_PA", normal: true },
{ label: "Overweight", low: 25.0, high: 29.9, citation: "WHO_2020_PA" },
{ label: "Obesity", low: 30.0, citation: "WHO_2020_PA" },
],
@@ -257,7 +272,7 @@ export const REFERENCE_RANGES: Record = {
STEPS: {
unit: "steps/day",
bands: [
- { label: "Higher mortality benefit", low: 8000, citation: "STEPS_SAINT_MAURICE_2020" },
+ { label: "Higher mortality benefit", low: 8000, citation: "STEPS_SAINT_MAURICE_2020", normal: true },
],
referenceId: "STEPS_SAINT_MAURICE_2020",
guidanceCaveat:
@@ -267,7 +282,7 @@ export const REFERENCE_RANGES: Record = {
VISCERAL_FAT: {
unit: "rating",
bands: [
- { label: "Healthy rating", low: 1, high: 12, citation: "VAT_IMAGING_THRESHOLD" },
+ { label: "Healthy rating", low: 1, high: 12, citation: "VAT_IMAGING_THRESHOLD", normal: true },
{ label: "Elevated rating", low: 13, citation: "VAT_IMAGING_THRESHOLD" },
],
referenceId: "VAT_IMAGING_THRESHOLD",
@@ -279,7 +294,7 @@ export const REFERENCE_RANGES: Record = {
SLEEP_DURATION: {
unit: "h",
bands: [
- { label: "Adult 18–64", low: 7, high: 9, citation: "AASM_2015_SLEEP" },
+ { label: "Adult 18–64", low: 7, high: 9, citation: "AASM_2015_SLEEP", normal: true },
{ label: "Older adult 65+", low: 7, high: 8, citation: "AASM_2015_SLEEP" },
],
referenceId: "AASM_2015_SLEEP",
@@ -337,6 +352,20 @@ export type ReferencePlacement =
* baseline). Pure + side-effect free; the user's own baseline still leads
* the read — this is only a coarse placement aid.
*/
+/**
+ * Resolve the index of a metric's population-normal band — the band the
+ * placement contract treats as "within". Returns the index of the band
+ * flagged `normal: true`; falls back to band 0 only for the legacy
+ * (untagged) shape so an unmarked metric degrades to the old normal-first
+ * assumption rather than throwing. A metric with no bands returns -1.
+ */
+export function normalBandIndex(metric: ReferenceMetric): number {
+ const bands = REFERENCE_RANGES[metric].bands;
+ if (bands.length === 0) return -1;
+ const marked = bands.findIndex((b) => b.normal === true);
+ return marked === -1 ? 0 : marked;
+}
+
export function classifyReference(
metric: ReferenceMetric,
value: number | null | undefined,
@@ -345,26 +374,26 @@ export function classifyReference(
if (value == null || !Number.isFinite(value) || range.bands.length === 0) {
return "insufficient";
}
- // Find the first band whose half-open interval contains the value.
- // Bands are authored in ascending order; the FIRST band is the
- // normal/good placement, so its membership maps to `within`.
+ const normalIdx = normalBandIndex(metric);
+ // Find the band whose half-open interval contains the value. Bands are
+ // authored in ascending order; the placement is the band's DISTANCE from
+ // the normal band (resolved by the `normal` marker, NOT by index 0 —
+ // some metrics author an abnormal band first, e.g. BMI Underweight,
+ // pulse-pressure Narrow). One step from normal = watch tier; two or more
+ // = attention tier.
for (let i = 0; i < range.bands.length; i++) {
const band = range.bands[i];
const aboveLow = band.low == null || value >= band.low;
const belowHigh = band.high == null || value <= band.high;
if (aboveLow && belowHigh) {
- if (i === 0) return "within";
- // The band immediately adjacent to normal is the watch tier; any
- // band beyond it is the attention tier.
- return i === 1 ? "slightly-outside" : "outside";
+ const distance = Math.abs(i - normalIdx);
+ if (distance === 0) return "within";
+ return distance === 1 ? "slightly-outside" : "outside";
}
}
- // Value sits beyond every authored band (e.g. below an open-low normal,
- // or above the top band's open-high). Treat an under-shoot of the first
- // band's low as within when the first band is open-low; otherwise the
- // value is outside the covered range → attention.
- const first = range.bands[0];
- if (first.low != null && value < first.low) return "outside";
+ // Value sits beyond every authored band (below an open-low first band, or
+ // above an open-high last band). Either way it is outside the covered
+ // bands → attention.
return "outside";
}
From c69e6deb6778d0a0efd2581b258574087692def6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 21:11:18 +0200
Subject: [PATCH 43/71] fix(sleep): drop the misleading last-night timeline for
estimated stages
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The last-night card drew a stepped stage timeline for every source. For a
source that reports per-stage totals without measured onsets (WHOOP / Polar),
the server lays the segments out in a fixed physiological order, so the bar
invents a phase progression that was never recorded — it reads as one Core
block then one Deep block, not a real hypnogram.
Suppress the timeline when the night is reconstructed and keep the per-stage
breakdown + totals, which are real. A calm note explains that stage timing is
estimated for that source. Real-series nights (Apple Health, Withings, Fitbit)
keep the measured timeline unchanged.
---
messages/de.json | 3 +-
messages/en.json | 3 +-
messages/es.json | 3 +-
messages/fr.json | 3 +-
messages/it.json | 3 +-
messages/pl.json | 3 +-
.../__tests__/sleep-hypnogram.test.tsx | 24 ++++++++++++++
src/components/insights/sleep-hypnogram.tsx | 33 +++++++++++++++++--
8 files changed, 67 insertions(+), 8 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index 3dcc80763..4e8cd7244 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2547,7 +2547,8 @@
"asleepTotal": "Geschlafen {value}",
"inBedTotal": "Im Bett {value}",
"awakenings": "{count} Wachphasen",
- "source": "Quelle: {source}"
+ "source": "Quelle: {source}",
+ "estimateNote": "Die Phasen-Zeiten sind geschätzt — diese Quelle liefert Summen je Phase, keinen gemessenen Verlauf."
},
"stages": {
"deep": "Tief",
diff --git a/messages/en.json b/messages/en.json
index a87949920..5c4c0206d 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2547,7 +2547,8 @@
"asleepTotal": "Asleep {value}",
"inBedTotal": "In bed {value}",
"awakenings": "{count} awakenings",
- "source": "Source: {source}"
+ "source": "Source: {source}",
+ "estimateNote": "Stage timing is estimated — this source reports totals per stage, not a measured timeline."
},
"stages": {
"deep": "Deep",
diff --git a/messages/es.json b/messages/es.json
index 99b6d7782..a69ad908c 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2547,7 +2547,8 @@
"asleepTotal": "Dormido {value}",
"inBedTotal": "En la cama {value}",
"awakenings": "{count} despertares",
- "source": "Fuente: {source}"
+ "source": "Fuente: {source}",
+ "estimateNote": "Los tiempos de cada fase son estimados: esta fuente informa totales por fase, no una cronología medida."
},
"stages": {
"deep": "Profundo",
diff --git a/messages/fr.json b/messages/fr.json
index 85cb525e9..8ca52cec5 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2547,7 +2547,8 @@
"asleepTotal": "Sommeil {value}",
"inBedTotal": "Au lit {value}",
"awakenings": "{count} réveils",
- "source": "Source : {source}"
+ "source": "Source : {source}",
+ "estimateNote": "Les durées des phases sont estimées : cette source indique des totaux par phase, pas une chronologie mesurée."
},
"stages": {
"deep": "Profond",
diff --git a/messages/it.json b/messages/it.json
index 48507ed1d..7608d2186 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2547,7 +2547,8 @@
"asleepTotal": "Sonno {value}",
"inBedTotal": "A letto {value}",
"awakenings": "{count} risvegli",
- "source": "Fonte: {source}"
+ "source": "Fonte: {source}",
+ "estimateNote": "I tempi delle fasi sono stimati: questa sorgente riporta i totali per fase, non una cronologia misurata."
},
"stages": {
"deep": "Profondo",
diff --git a/messages/pl.json b/messages/pl.json
index c60c651f4..32e690173 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2547,7 +2547,8 @@
"asleepTotal": "Sen {value}",
"inBedTotal": "W łóżku {value}",
"awakenings": "{count} przebudzeń",
- "source": "Źródło: {source}"
+ "source": "Źródło: {source}",
+ "estimateNote": "Czasy faz są szacowane — to źródło podaje sumy dla każdej fazy, a nie zmierzony przebieg."
},
"stages": {
"deep": "Głęboki",
diff --git a/src/components/insights/__tests__/sleep-hypnogram.test.tsx b/src/components/insights/__tests__/sleep-hypnogram.test.tsx
index a04a0ea17..6452cd205 100644
--- a/src/components/insights/__tests__/sleep-hypnogram.test.tsx
+++ b/src/components/insights/__tests__/sleep-hypnogram.test.tsx
@@ -127,6 +127,30 @@ describe(" — timeline bar visibility", () => {
expect(render(session)).toContain('role="img"');
});
+ it("suppresses the timeline bar for a SYNTHESISED (reconstructed) night", () => {
+ // WHOOP / Polar report per-stage TOTALS and the server lays the segments
+ // out in a fixed physiological order — the timeline would invent a phase
+ // progression that was never measured. So even with distinct, well-timed
+ // spans, a `reconstructed` night drops the bar and keeps the breakdown.
+ const session: SleepHypnogramSession = {
+ ...baseSession,
+ source: "WHOOP",
+ reconstructed: true,
+ };
+ const html = render(session);
+ expect(html).not.toContain('role="img"');
+ expect(html).toContain('data-slot="sleep-hypnogram-breakdown"');
+ expect(html).toContain('data-slot="sleep-hypnogram-estimate-note"');
+ });
+
+ it("keeps the timeline bar for a measured (non-reconstructed) night", () => {
+ // An Apple Health night carries real per-stage onsets, so the stepped bar
+ // is genuine and stays. The estimate note never appears.
+ const html = render({ ...baseSession, reconstructed: false });
+ expect(html).toContain('role="img"');
+ expect(html).not.toContain('data-slot="sleep-hypnogram-estimate-note"');
+ });
+
it("collapses to the breakdown footer for a degenerate single-instant session", () => {
// Every segment stamped on ONE instant — no real timeline, so only the
// breakdown footer, no bar.
diff --git a/src/components/insights/sleep-hypnogram.tsx b/src/components/insights/sleep-hypnogram.tsx
index 27b341252..7e6797140 100644
--- a/src/components/insights/sleep-hypnogram.tsx
+++ b/src/components/insights/sleep-hypnogram.tsx
@@ -58,6 +58,17 @@ export interface SleepHypnogramSession {
awakenings: number;
stages: Record;
segments: SleepHypnogramSegment[];
+ /**
+ * `true` when the winning source has no measured per-stage onset
+ * timestamps and the server synthesised a contiguous timeline in a fixed
+ * physiological order (WHOOP / Polar). The "timeline" of such a night is
+ * not a real hypnogram — it would read as one long Core block followed by
+ * one Deep block, never the phase-to-phase progression a measured night
+ * shows — so we never draw the misleading stepped bar for it and fall back
+ * to the per-stage breakdown alone. A real-series source (Apple Health,
+ * Withings, Fitbit) is `false` and keeps the timeline.
+ */
+ reconstructed?: boolean;
}
export interface SleepHypnogramProps {
@@ -216,10 +227,16 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
// breakdown footer. (Gating on the END instant instead hid the bar for an
// ordinary multi-stage night whose segments happened to share a right
// edge — the regression this restores.)
+ // A SYNTHESISED night (WHOOP / Polar) has no measured stage onsets — the
+ // server laid its segments out in a fixed physiological order, so drawing
+ // the stepped bar would invent a phase progression that was never recorded
+ // (the "Core block then Deep block, no real breakdown" the bar reads as).
+ // For those we keep the per-stage breakdown + totals, which ARE real, and
+ // drop the misleading timeline entirely. Real-series sources keep the bar.
const hasTrack =
+ session.reconstructed !== true &&
spans.length > 0 &&
- (spans.length === 1 ||
- new Set(spans.map((s) => s.x1)).size > 1);
+ (spans.length === 1 || new Set(spans.map((s) => s.x1)).size > 1);
const hasBreakdown = breakdown.length > 0;
return (
@@ -251,6 +268,18 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
})}
) : null}
+ {/* When the source reports stage TOTALS but no measured onsets
+ (WHOOP / Polar) the stepped timeline is suppressed — drawing one
+ would invent a phase order that was never recorded. Surface a calm
+ note so the breakdown below doesn't read as a missing chart. */}
+ {session.reconstructed === true && hasBreakdown ? (
+
+ {t("insights.sleep.hypnogram.estimateNote")}
+
+ ) : null}
From d8c139698c8b49655979904fdbddf56651989e82 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 21:16:43 +0200
Subject: [PATCH 44/71] feat(sleep): surface the chronotype as a labelled
corner readout
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Move the chronotype band out of a body badge into a labelled top-right corner
of the card — a small "Chronotype" label over the value — so the resolved
type is the first thing the eye lands on. Tighten the mid-sleep copy to name
it as the body's natural sleep midpoint on free days.
---
messages/de.json | 28 +++++++++--
messages/en.json | 28 +++++++++--
messages/es.json | 28 +++++++++--
messages/fr.json | 28 +++++++++--
messages/it.json | 28 +++++++++--
messages/pl.json | 28 +++++++++--
.../__tests__/sleep-rhythm-cards.test.tsx | 8 ++++
.../insights/sleep/chronotype-card.tsx | 47 ++++++++++++-------
8 files changed, 187 insertions(+), 36 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index 4e8cd7244..8f654ac76 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2575,7 +2575,7 @@
"chronotype": {
"title": "Chronotyp",
"learning": "Dein Rhythmus wird noch erfasst — {counted} von {total} freien Nächten. Ein Chronotyp braucht ein paar weckerfreie Morgen, um aussagekräftig zu sein.",
- "midSleepCaption": "Schlafmitte an freien Tagen gegen {clock}",
+ "midSleepCaption": "Deine natürliche Schlafmitte an freien Tagen liegt etwa um {clock}.",
"advancedToggle": "Mehr Details",
"socialJetlagValue": "{hours} Std. {minutes} Min. sozialer Jetlag",
"socialJetlagCaption": "Der Unterschied zwischen deinem Schlaf an Arbeits- und freien Tagen — wie eine kleine wöchentliche Zeitzonenverschiebung.",
@@ -2588,7 +2588,8 @@
"intermediate": "Mischtyp",
"late": "Abendtyp",
"extreme_late": "Ausgeprägter Abendtyp"
- }
+ },
+ "cornerLabel": "Chronotyp"
},
"rhythm": {
"loadError": "Dein Schlafrhythmus konnte gerade nicht geladen werden. Er erscheint wieder, sobald die Verbindung steht."
@@ -3480,7 +3481,28 @@
},
"sleepQuality": {
"title": "Schlafqualität",
- "subtitle": "Nächtliche Werte deines Wearables"
+ "subtitle": "Nächtliche Werte deines Wearables",
+ "assessment": {
+ "metric": {
+ "SLEEP_SCORE": "dein Gesamt-Schlafscore",
+ "SLEEP_EFFICIENCY": "die Schlafeffizienz",
+ "SLEEP_PERFORMANCE": "die Schlafleistung",
+ "SLEEP_CONSISTENCY": "die Schlafkonstanz"
+ },
+ "grade": {
+ "excellent": "ausgezeichnet",
+ "good": "solide",
+ "fair": "durchschnittlich",
+ "low": "eher niedrig"
+ },
+ "clause": "Über die letzten 30 Nächte ist {metric} mit {value} {grade}.",
+ "closing": {
+ "excellent": "Behalte die Routine bei, die dich hierher bringt.",
+ "good": "Konstantere Zubett- und Aufstehzeiten sind meist der nächste Gewinn.",
+ "fair": "Ein konstantes Schlaffenster hebt diesen Wert erfahrungsgemäß am stärksten.",
+ "low": "Ein festes Herunterfahren am Abend und eine feste Aufstehzeit sind die ersten Hebel."
+ }
+ }
},
"recovery": {
"title": "Erholung",
diff --git a/messages/en.json b/messages/en.json
index 5c4c0206d..accbf337f 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2575,7 +2575,7 @@
"chronotype": {
"title": "Chronotype",
"learning": "Still learning your rhythm — {counted} of {total} free nights. A chronotype needs a few alarm-free mornings before it means anything.",
- "midSleepCaption": "Mid-sleep on free days around {clock}",
+ "midSleepCaption": "Your natural sleep midpoint on free days lands around {clock}.",
"advancedToggle": "Advanced",
"socialJetlagValue": "{hours}h {minutes}m social jetlag",
"socialJetlagCaption": "The gap between your work-day and free-day sleep timing — like a small weekly time-zone shift.",
@@ -2588,7 +2588,8 @@
"intermediate": "Intermediate type",
"late": "Evening type",
"extreme_late": "Strong evening type"
- }
+ },
+ "cornerLabel": "Chronotype"
},
"rhythm": {
"loadError": "Your sleep rhythm couldn't load just now. It'll reappear once the connection settles."
@@ -3480,7 +3481,28 @@
},
"sleepQuality": {
"title": "Sleep quality",
- "subtitle": "Nightly scores from your wearable"
+ "subtitle": "Nightly scores from your wearable",
+ "assessment": {
+ "metric": {
+ "SLEEP_SCORE": "your overall sleep score",
+ "SLEEP_EFFICIENCY": "sleep efficiency",
+ "SLEEP_PERFORMANCE": "sleep performance",
+ "SLEEP_CONSISTENCY": "sleep consistency"
+ },
+ "grade": {
+ "excellent": "excellent",
+ "good": "solid",
+ "fair": "fair",
+ "low": "on the low side"
+ },
+ "clause": "Over the last 30 nights {metric} is {grade} at {value}.",
+ "closing": {
+ "excellent": "Keep the routine that is getting you here.",
+ "good": "A steadier bed and wake time is the usual next gain.",
+ "fair": "Protecting a consistent sleep window tends to lift this most.",
+ "low": "A regular wind-down and a fixed wake time are the first levers to pull."
+ }
+ }
},
"recovery": {
"title": "Recovery",
diff --git a/messages/es.json b/messages/es.json
index a69ad908c..9fa3e2b1e 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2575,7 +2575,7 @@
"chronotype": {
"title": "Cronotipo",
"learning": "Aún estamos aprendiendo tu ritmo — {counted} de {total} noches libres. Un cronotipo necesita varias mañanas sin despertador para tener sentido.",
- "midSleepCaption": "Punto medio del sueño en días libres en torno a las {clock}",
+ "midSleepCaption": "El punto medio natural de tu sueño en días libres ronda las {clock}.",
"advancedToggle": "Avanzado",
"socialJetlagValue": "{hours} h {minutes} min de jet lag social",
"socialJetlagCaption": "La diferencia entre el horario de sueño laboral y el de los días libres — como un pequeño cambio de huso horario semanal.",
@@ -2588,7 +2588,8 @@
"intermediate": "Intermedio",
"late": "Vespertino",
"extreme_late": "Vespertino marcado"
- }
+ },
+ "cornerLabel": "Cronotipo"
},
"rhythm": {
"loadError": "Tu ritmo de sueño no pudo cargarse ahora mismo. Reaparecerá en cuanto se estabilice la conexión."
@@ -3480,7 +3481,28 @@
},
"sleepQuality": {
"title": "Calidad del sueño",
- "subtitle": "Puntuaciones nocturnas de tu wearable"
+ "subtitle": "Puntuaciones nocturnas de tu wearable",
+ "assessment": {
+ "metric": {
+ "SLEEP_SCORE": "tu puntuación global de sueño",
+ "SLEEP_EFFICIENCY": "la eficiencia del sueño",
+ "SLEEP_PERFORMANCE": "el rendimiento del sueño",
+ "SLEEP_CONSISTENCY": "la regularidad del sueño"
+ },
+ "grade": {
+ "excellent": "excelente",
+ "good": "sólida",
+ "fair": "aceptable",
+ "low": "más bien baja"
+ },
+ "clause": "En las últimas 30 noches {metric} es {grade}, en {value}.",
+ "closing": {
+ "excellent": "Mantén la rutina que te trae hasta aquí.",
+ "good": "Acostarte y levantarte a horas más estables suele ser la siguiente mejora.",
+ "fair": "Proteger una franja de sueño constante es lo que más suele subir esto.",
+ "low": "Una rutina de relajación regular y una hora fija para despertar son las primeras palancas."
+ }
+ }
},
"recovery": {
"title": "Recuperación",
diff --git a/messages/fr.json b/messages/fr.json
index 8ca52cec5..9c1a433c0 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2575,7 +2575,7 @@
"chronotype": {
"title": "Chronotype",
"learning": "Votre rythme est encore en cours d'analyse — {counted} sur {total} nuits libres. Un chronotype a besoin de quelques matins sans réveil pour avoir du sens.",
- "midSleepCaption": "Milieu de sommeil les jours libres vers {clock}",
+ "midSleepCaption": "Le milieu naturel de votre sommeil les jours de repos se situe vers {clock}.",
"advancedToggle": "Avancé",
"socialJetlagValue": "{hours} h {minutes} min de jet lag social",
"socialJetlagCaption": "L'écart entre vos horaires de sommeil les jours travaillés et les jours libres — comme un petit décalage horaire hebdomadaire.",
@@ -2588,7 +2588,8 @@
"intermediate": "Type intermédiaire",
"late": "Type du soir",
"extreme_late": "Type du soir marqué"
- }
+ },
+ "cornerLabel": "Chronotype"
},
"rhythm": {
"loadError": "Votre rythme de sommeil n'a pas pu se charger pour l'instant. Il réapparaîtra dès que la connexion sera rétablie."
@@ -3480,7 +3481,28 @@
},
"sleepQuality": {
"title": "Qualité du sommeil",
- "subtitle": "Scores nocturnes de votre wearable"
+ "subtitle": "Scores nocturnes de votre wearable",
+ "assessment": {
+ "metric": {
+ "SLEEP_SCORE": "votre score de sommeil global",
+ "SLEEP_EFFICIENCY": "l’efficacité du sommeil",
+ "SLEEP_PERFORMANCE": "la performance de sommeil",
+ "SLEEP_CONSISTENCY": "la régularité du sommeil"
+ },
+ "grade": {
+ "excellent": "excellente",
+ "good": "solide",
+ "fair": "correcte",
+ "low": "plutôt faible"
+ },
+ "clause": "Sur les 30 dernières nuits, {metric} est {grade}, à {value}.",
+ "closing": {
+ "excellent": "Gardez la routine qui vous mène jusque-là.",
+ "good": "Des heures de coucher et de lever plus stables sont souvent le prochain gain.",
+ "fair": "Protéger une fenêtre de sommeil constante est ce qui fait le plus progresser ce point.",
+ "low": "Un rituel d’endormissement régulier et une heure de réveil fixe sont les premiers leviers."
+ }
+ }
},
"recovery": {
"title": "Récupération",
diff --git a/messages/it.json b/messages/it.json
index 7608d2186..77ba27740 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2575,7 +2575,7 @@
"chronotype": {
"title": "Cronotipo",
"learning": "Stiamo ancora imparando il tuo ritmo — {counted} di {total} notti libere. Un cronotipo richiede alcune mattine senza sveglia per avere significato.",
- "midSleepCaption": "Metà del sonno nei giorni liberi intorno alle {clock}",
+ "midSleepCaption": "Il punto medio naturale del tuo sonno nei giorni liberi è intorno alle {clock}.",
"advancedToggle": "Avanzate",
"socialJetlagValue": "{hours} h {minutes} min di jet lag sociale",
"socialJetlagCaption": "La differenza tra gli orari del sonno nei giorni lavorativi e in quelli liberi — come un piccolo cambio di fuso orario settimanale.",
@@ -2588,7 +2588,8 @@
"intermediate": "Tipo intermedio",
"late": "Tipo serale",
"extreme_late": "Tipo serale marcato"
- }
+ },
+ "cornerLabel": "Cronotipo"
},
"rhythm": {
"loadError": "Non è stato possibile caricare il tuo ritmo del sonno in questo momento. Riapparirà non appena la connessione sarà stabile."
@@ -3480,7 +3481,28 @@
},
"sleepQuality": {
"title": "Qualità del sonno",
- "subtitle": "Punteggi notturni del tuo wearable"
+ "subtitle": "Punteggi notturni del tuo wearable",
+ "assessment": {
+ "metric": {
+ "SLEEP_SCORE": "il tuo punteggio di sonno complessivo",
+ "SLEEP_EFFICIENCY": "l’efficienza del sonno",
+ "SLEEP_PERFORMANCE": "la prestazione del sonno",
+ "SLEEP_CONSISTENCY": "la regolarità del sonno"
+ },
+ "grade": {
+ "excellent": "eccellente",
+ "good": "solida",
+ "fair": "discreta",
+ "low": "piuttosto bassa"
+ },
+ "clause": "Nelle ultime 30 notti {metric} è {grade}, a {value}.",
+ "closing": {
+ "excellent": "Mantieni la routine che ti porta fin qui.",
+ "good": "Orari di sonno e risveglio più stabili sono di solito il prossimo guadagno.",
+ "fair": "Proteggere una finestra di sonno costante è ciò che alza di più questo valore.",
+ "low": "Una routine serale regolare e un orario di sveglia fisso sono le prime leve."
+ }
+ }
},
"recovery": {
"title": "Recupero",
diff --git a/messages/pl.json b/messages/pl.json
index 32e690173..6bd2a8336 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2575,7 +2575,7 @@
"chronotype": {
"title": "Chronotyp",
"learning": "Wciąż poznajemy Twój rytm — {counted} z {total} wolnych nocy. Chronotyp wymaga kilku poranków bez budzika, aby coś znaczył.",
- "midSleepCaption": "Środek snu w dni wolne około {clock}",
+ "midSleepCaption": "Naturalny środek Twojego snu w dni wolne przypada około {clock}.",
"advancedToggle": "Zaawansowane",
"socialJetlagValue": "{hours} godz. {minutes} min jet lagu społecznego",
"socialJetlagCaption": "Różnica między porą snu w dni robocze i wolne — jak niewielka cotygodniowa zmiana strefy czasowej.",
@@ -2588,7 +2588,8 @@
"intermediate": "Typ pośredni",
"late": "Typ wieczorny",
"extreme_late": "Wyraźny typ wieczorny"
- }
+ },
+ "cornerLabel": "Chronotyp"
},
"rhythm": {
"loadError": "Nie udało się teraz wczytać Twojego rytmu snu. Pojawi się ponownie, gdy połączenie się ustabilizuje."
@@ -3480,7 +3481,28 @@
},
"sleepQuality": {
"title": "Jakość snu",
- "subtitle": "Nocne wyniki z Twojego urządzenia"
+ "subtitle": "Nocne wyniki z Twojego urządzenia",
+ "assessment": {
+ "metric": {
+ "SLEEP_SCORE": "Twój ogólny wynik snu",
+ "SLEEP_EFFICIENCY": "efektywność snu",
+ "SLEEP_PERFORMANCE": "wydajność snu",
+ "SLEEP_CONSISTENCY": "regularność snu"
+ },
+ "grade": {
+ "excellent": "doskonała",
+ "good": "solidna",
+ "fair": "przeciętna",
+ "low": "raczej niska"
+ },
+ "clause": "W ciągu ostatnich 30 nocy {metric} jest {grade} i wynosi {value}.",
+ "closing": {
+ "excellent": "Trzymaj się rutyny, która Cię tu doprowadziła.",
+ "good": "Bardziej stałe pory zasypiania i wstawania to zwykle następny zysk.",
+ "fair": "Utrzymanie stałego okna snu zwykle podnosi ten wynik najbardziej.",
+ "low": "Regularne wyciszenie wieczorem i stała pora pobudki to pierwsze dźwignie."
+ }
+ }
},
"recovery": {
"title": "Regeneracja",
diff --git a/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx
index 7e1dc23ae..ffa8e2572 100644
--- a/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx
+++ b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx
@@ -110,6 +110,14 @@ describe("", () => {
expect(html).toContain("04:30");
});
+ it("renders the band as a labelled top-right corner readout, not a badge", () => {
+ const html = render();
+ // The labelled corner carries both the "Chronotype" label and the value.
+ expect(html).toContain('data-slot="chronotype-corner"');
+ expect(html).toContain("Chronotype");
+ expect(html).toContain("Intermediate type");
+ });
+
it("inherits the standard Card rhythm (no compact override)", () => {
const learning = render();
const ready = render();
diff --git a/src/components/insights/sleep/chronotype-card.tsx b/src/components/insights/sleep/chronotype-card.tsx
index 1932e7b92..9b7ebfb4c 100644
--- a/src/components/insights/sleep/chronotype-card.tsx
+++ b/src/components/insights/sleep/chronotype-card.tsx
@@ -5,7 +5,6 @@ import { Clock, ChevronDown, ChevronUp } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
-import { Badge } from "@/components/ui/badge";
import { LearningGate } from "@/components/ui/learning-gate";
import type { ChronotypeDto } from "./use-sleep-rhythm";
@@ -67,26 +66,38 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
return (
-
-
-
- {t("insights.sleep.chronotype.title")}
-
+ {/* The resolved chronotype rides the top-right corner as a labelled
+ readout — a small "Chronotyp" label over the band value — rather
+ than a badge buried in the body. The title stays left; the value
+ is the thing the eye should land on first. */}
+
+ )}
Date: Thu, 18 Jun 2026 21:17:00 +0200
Subject: [PATCH 45/71] feat(sleep): ground the sleep-quality assessment in the
user's own scores
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The sleep-quality "Einschätzung" read "no assessment yet" until an AI provider
had run, and stayed blank on an account with no provider configured. Fill that
slot with a deterministic note built from the user's own quality averages —
efficiency, performance, consistency and the headline score graded against
recognised reference bands — so the assessment is always substantive. The
richer AI narrative still wins when present; the grounded note only steps in
once the assessment query settles with no narrative, so the two never stack.
---
.../sleep-quality-assessment.test.ts | 68 +++++++++
.../sleep-quality-grounded-note.test.tsx | 67 +++++++++
.../sleep/sleep-quality-assessment.ts | 136 ++++++++++++++++++
.../sleep/sleep-quality-grounded-note.tsx | 112 +++++++++++++++
.../insights/sleep/sleep-quality-section.tsx | 67 +++++++--
5 files changed, 438 insertions(+), 12 deletions(-)
create mode 100644 src/components/insights/sleep/__tests__/sleep-quality-assessment.test.ts
create mode 100644 src/components/insights/sleep/__tests__/sleep-quality-grounded-note.test.tsx
create mode 100644 src/components/insights/sleep/sleep-quality-assessment.ts
create mode 100644 src/components/insights/sleep/sleep-quality-grounded-note.tsx
diff --git a/src/components/insights/sleep/__tests__/sleep-quality-assessment.test.ts b/src/components/insights/sleep/__tests__/sleep-quality-assessment.test.ts
new file mode 100644
index 000000000..ec27b8be4
--- /dev/null
+++ b/src/components/insights/sleep/__tests__/sleep-quality-assessment.test.ts
@@ -0,0 +1,68 @@
+import { describe, it, expect } from "vitest";
+
+import { buildSleepQualityAssessment } from "../sleep-quality-assessment";
+
+/**
+ * v1.18.6 — the deterministic sleep-quality grading that backs the grounded
+ * "Einschätzung" when no AI narrative exists. Pins the band thresholds and the
+ * worst-grade-wins overall, and the "skip ungradable metrics" contract.
+ */
+describe("buildSleepQualityAssessment", () => {
+ it("grades efficiency on the clinical 90 / 85 / 75 floors", () => {
+ expect(
+ buildSleepQualityAssessment([{ type: "SLEEP_EFFICIENCY", value: 92 }])
+ ?.lead.grade,
+ ).toBe("excellent");
+ expect(
+ buildSleepQualityAssessment([{ type: "SLEEP_EFFICIENCY", value: 86 }])
+ ?.lead.grade,
+ ).toBe("good");
+ expect(
+ buildSleepQualityAssessment([{ type: "SLEEP_EFFICIENCY", value: 78 }])
+ ?.lead.grade,
+ ).toBe("fair");
+ expect(
+ buildSleepQualityAssessment([{ type: "SLEEP_EFFICIENCY", value: 60 }])
+ ?.lead.grade,
+ ).toBe("low");
+ });
+
+ it("grades 0–100 scores on the 90 / 85 / 70 split", () => {
+ expect(
+ buildSleepQualityAssessment([{ type: "SLEEP_SCORE", value: 95 }])?.lead
+ .grade,
+ ).toBe("excellent");
+ expect(
+ buildSleepQualityAssessment([{ type: "SLEEP_SCORE", value: 72 }])?.lead
+ .grade,
+ ).toBe("fair");
+ });
+
+ it("opens on the headline score and keeps worst-grade-wins overall", () => {
+ const a = buildSleepQualityAssessment([
+ { type: "SLEEP_CONSISTENCY", value: 60 },
+ { type: "SLEEP_SCORE", value: 95 },
+ { type: "SLEEP_EFFICIENCY", value: 92 },
+ ]);
+ expect(a?.lead.type).toBe("SLEEP_SCORE");
+ // The low consistency pulls the overall down even though the score is high.
+ expect(a?.overall).toBe("low");
+ expect(a?.rest.length).toBe(2);
+ });
+
+ it("skips metrics it has no recognised band for, and returns null on none", () => {
+ // SLEEP_NEED (minutes) + disturbance count are not gradable here.
+ expect(
+ buildSleepQualityAssessment([
+ { type: "SLEEP_NEED", value: 480 },
+ { type: "SLEEP_DISTURBANCE_COUNT", value: 3 },
+ ]),
+ ).toBeNull();
+ });
+
+ it("ignores non-finite values", () => {
+ expect(
+ buildSleepQualityAssessment([{ type: "SLEEP_SCORE", value: Number.NaN }]),
+ ).toBeNull();
+ });
+});
diff --git a/src/components/insights/sleep/__tests__/sleep-quality-grounded-note.test.tsx b/src/components/insights/sleep/__tests__/sleep-quality-grounded-note.test.tsx
new file mode 100644
index 000000000..f02cfc52d
--- /dev/null
+++ b/src/components/insights/sleep/__tests__/sleep-quality-grounded-note.test.tsx
@@ -0,0 +1,67 @@
+import { describe, it, expect } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { SleepQualityGroundedNote } from "../sleep-quality-grounded-note";
+import type { DataSummary } from "@/lib/analytics/trends";
+
+/**
+ * v1.18.6 — the grounded sleep-quality note that fills the assessment slot
+ * when no AI narrative is available. Pins that it builds substantive copy from
+ * the user's own averages and that it self-suppresses when the AI is present.
+ */
+function summary(avg30: number): DataSummary {
+ return {
+ count: 30,
+ latest: avg30,
+ avg7: avg30,
+ avg30,
+ } as unknown as DataSummary;
+}
+
+function render(node: React.ReactNode, locale: "en" | "de" = "en") {
+ return renderToStaticMarkup(
+ {node},
+ );
+}
+
+describe("", () => {
+ const summaries = {
+ SLEEP_SCORE: summary(82),
+ SLEEP_EFFICIENCY: summary(88),
+ };
+
+ it("renders a substantive, grounded assessment when the AI is absent", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-slot="sleep-quality-grounded-note"');
+ // The headline score (82 → fair on the 90/85/70 split) leads the read.
+ expect(html).toContain("overall sleep score");
+ expect(html).toContain("fair");
+ expect(html).toContain("82");
+ });
+
+ it("renders nothing when the AI assessment is present", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toBe("");
+ });
+
+ it("renders nothing when no quality metric is gradable", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toBe("");
+ });
+});
diff --git a/src/components/insights/sleep/sleep-quality-assessment.ts b/src/components/insights/sleep/sleep-quality-assessment.ts
new file mode 100644
index 000000000..b2799a3d3
--- /dev/null
+++ b/src/components/insights/sleep/sleep-quality-assessment.ts
@@ -0,0 +1,136 @@
+/**
+ * v1.18.6 — deterministic, data-grounded sleep-quality assessment.
+ *
+ * The AI "Einschätzung" under the sleep-quality block reads "no assessment
+ * yet / unavailable" until a provider has run (and never at all on an account
+ * with no AI provider). That left the most universally useful read — the one
+ * the user's OWN numbers already support — blank. This pure helper turns the
+ * sleep-quality summaries that the section already has in hand into a short,
+ * grounded sentence: it names the night-over-night averages and grades them
+ * against recognised reference ranges, with NO provider call and NO recompute
+ * of a stored value (it only reads the server-authoritative averages).
+ *
+ * Grading thresholds are the conventional consumer-sleep-platform bands:
+ * - Sleep efficiency: ≥ 85 % is the textbook "good sleeper" floor (AASM);
+ * ≥ 90 % is excellent, < 75 % is poor.
+ * - Performance / consistency / headline score: 0–100, graded on the
+ * standard 85 / 70 split most rings use (good / fair / low).
+ * The copy is intentionally cautious — it describes, it never diagnoses.
+ *
+ * Kept free of React + i18n so the grading is unit-testable in isolation; the
+ * component maps the returned shape onto localised strings.
+ */
+
+export type QualityGrade = "excellent" | "good" | "fair" | "low";
+
+export interface SleepQualityMetricRead {
+ /** Registry/measurement type, e.g. `SLEEP_EFFICIENCY`. */
+ type: string;
+ /** The averaged value the section already holds (avg30 ?? avg7 ?? latest). */
+ value: number;
+}
+
+export interface SleepQualityFinding {
+ type: string;
+ value: number;
+ grade: QualityGrade;
+}
+
+export interface SleepQualityAssessment {
+ /** The lead metric the assessment opens on (best-known quality signal). */
+ lead: SleepQualityFinding;
+ /** The remaining graded metrics, most-informative first. */
+ rest: SleepQualityFinding[];
+ /** Worst grade across the present metrics — drives the closing nudge. */
+ overall: QualityGrade;
+}
+
+/** Percent-scaled 0–100 metrics graded on the 90 / 85 / 70 ring split. */
+const PERCENT_METRICS = new Set([
+ "SLEEP_SCORE",
+ "SLEEP_PERFORMANCE",
+ "SLEEP_CONSISTENCY",
+]);
+
+/** Efficiency uses the clinical 90 / 85 / 75 floors rather than the ring split. */
+function gradeEfficiency(pct: number): QualityGrade {
+ if (pct >= 90) return "excellent";
+ if (pct >= 85) return "good";
+ if (pct >= 75) return "fair";
+ return "low";
+}
+
+function gradeScore(value: number): QualityGrade {
+ if (value >= 90) return "excellent";
+ if (value >= 85) return "good";
+ if (value >= 70) return "fair";
+ return "low";
+}
+
+/**
+ * Grade one quality metric. Returns null for a metric the helper has no
+ * recognised band for (e.g. raw disturbance counts or sleep-need minutes),
+ * so the assessment never grades a value against a scale it does not fit.
+ */
+function gradeMetric(type: string, value: number): QualityGrade | null {
+ if (type === "SLEEP_EFFICIENCY") return gradeEfficiency(value);
+ if (PERCENT_METRICS.has(type)) return gradeScore(value);
+ return null;
+}
+
+/** Worst grade wins — a single low signal pulls the overall read down. */
+const GRADE_RANK: Record = {
+ excellent: 3,
+ good: 2,
+ fair: 1,
+ low: 0,
+};
+
+/**
+ * Lead-metric preference: the headline score reads first when present, then
+ * efficiency, then performance, then consistency. Keeps the opening sentence
+ * anchored on the most holistic signal the night carries.
+ */
+const LEAD_ORDER = [
+ "SLEEP_SCORE",
+ "SLEEP_EFFICIENCY",
+ "SLEEP_PERFORMANCE",
+ "SLEEP_CONSISTENCY",
+] as const;
+
+/**
+ * Build a deterministic assessment from the present quality metrics. Returns
+ * null when none of the supplied metrics is gradable, so the caller falls back
+ * to its existing copy rather than printing an empty read.
+ */
+export function buildSleepQualityAssessment(
+ reads: readonly SleepQualityMetricRead[],
+): SleepQualityAssessment | null {
+ const findings: SleepQualityFinding[] = [];
+ for (const r of reads) {
+ if (!Number.isFinite(r.value)) continue;
+ const grade = gradeMetric(r.type, r.value);
+ if (grade == null) continue;
+ findings.push({ type: r.type, value: r.value, grade });
+ }
+ if (findings.length === 0) return null;
+
+ const leadIndex = (type: string): number => {
+ const i = LEAD_ORDER.indexOf(type as (typeof LEAD_ORDER)[number]);
+ return i === -1 ? Number.MAX_SAFE_INTEGER : i;
+ };
+ const sorted = [...findings].sort(
+ (a, b) => leadIndex(a.type) - leadIndex(b.type),
+ );
+
+ const overall = sorted.reduce(
+ (worst, f) => (GRADE_RANK[f.grade] < GRADE_RANK[worst] ? f.grade : worst),
+ "excellent",
+ );
+
+ return {
+ lead: sorted[0],
+ rest: sorted.slice(1),
+ overall,
+ };
+}
diff --git a/src/components/insights/sleep/sleep-quality-grounded-note.tsx b/src/components/insights/sleep/sleep-quality-grounded-note.tsx
new file mode 100644
index 000000000..11a1000a3
--- /dev/null
+++ b/src/components/insights/sleep/sleep-quality-grounded-note.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+import { Moon } from "lucide-react";
+
+import { 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";
+import {
+ buildSleepQualityAssessment,
+ type QualityGrade,
+ type SleepQualityFinding,
+} from "./sleep-quality-assessment";
+
+/**
+ * v1.18.6 — the grounded sleep-quality "Einschätzung".
+ *
+ * The AI assessment under the sleep-quality block reads "no assessment yet"
+ * until a provider has run, and stays blank forever on an account with no AI
+ * provider. This card fills that gap with a substantive read built ENTIRELY
+ * from the user's own quality averages (efficiency / performance / consistency
+ * / headline score) graded against recognised reference bands — no provider,
+ * no recompute of a stored value. It describes; it does not diagnose.
+ *
+ * Mounted only when the AI assessment is NOT present (`showWhenAiAbsent`), so
+ * the richer AI narrative still wins when it exists and the user never sees
+ * two assessments stacked.
+ */
+
+const QUALITY_TYPES = [
+ "SLEEP_SCORE",
+ "SLEEP_EFFICIENCY",
+ "SLEEP_PERFORMANCE",
+ "SLEEP_CONSISTENCY",
+] as const;
+
+/** Map a finding to its localised "{metric} is {grade} at {value}" clause. */
+function clauseFor(
+ finding: SleepQualityFinding,
+ t: (key: string, vars?: Record) => string,
+): string {
+ const metricLabel = t(`insights.sleepQuality.assessment.metric.${finding.type}`);
+ const gradeLabel = t(`insights.sleepQuality.assessment.grade.${finding.grade}`);
+ // Efficiency / performance / consistency are percentages; the headline score
+ // is a 0–100 index. Both read naturally as a rounded integer.
+ const value = Math.round(finding.value);
+ return t("insights.sleepQuality.assessment.clause", {
+ metric: metricLabel,
+ grade: gradeLabel,
+ value,
+ });
+}
+
+export interface SleepQualityGroundedNoteProps {
+ /** The analytics summaries the section already holds (no extra fetch). */
+ summaries: Record | undefined;
+ /**
+ * Whether the AI assessment is absent. The grounded note only renders when
+ * the AI narrative is not there, so the two never stack.
+ */
+ showWhenAiAbsent: boolean;
+}
+
+export function SleepQualityGroundedNote({
+ summaries,
+ showWhenAiAbsent,
+}: SleepQualityGroundedNoteProps) {
+ const { t } = useTranslations();
+
+ if (!showWhenAiAbsent || !summaries) return null;
+
+ const reads = QUALITY_TYPES.map((type) => {
+ const s = summaries[type];
+ const value = s?.avg30 ?? s?.avg7 ?? s?.latest ?? null;
+ return value == null
+ ? null
+ : ({ type, value } as { type: string; value: number });
+ }).filter((r): r is { type: string; value: number } => r !== null);
+
+ const assessment = buildSleepQualityAssessment(reads);
+ if (!assessment) return null;
+
+ const clauses = [assessment.lead, ...assessment.rest].map((f) =>
+ clauseFor(f, t),
+ );
+ const closing = t(
+ `insights.sleepQuality.assessment.closing.${assessment.overall as QualityGrade}`,
+ );
+
+ return (
+
+
+
+
+
+
+ {clauses.join(" ")} {closing}
+
+
+
+ );
+}
+
+function QualityNoteIcon({ className }: { className?: string }) {
+ return ;
+}
diff --git a/src/components/insights/sleep/sleep-quality-section.tsx b/src/components/insights/sleep/sleep-quality-section.tsx
index f791f4862..51363ba9a 100644
--- a/src/components/insights/sleep/sleep-quality-section.tsx
+++ b/src/components/insights/sleep/sleep-quality-section.tsx
@@ -3,11 +3,14 @@
import { Activity, Gauge, Moon, Repeat, Target, Waves } from "lucide-react";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
+import { useInsightMetricStatus } from "@/hooks/use-insight-status";
import { useTranslations } from "@/lib/i18n/context";
+import type { DataSummary } from "@/lib/analytics/trends";
import { SectionHeading } from "@/components/insights/section-heading";
import { DeviceScoreTile } from "@/components/insights/device-score-tile";
import { DeviceScoreGridSkeleton } from "@/components/insights/device-score-tile-skeleton";
import { MetricStatusCard } from "@/components/insights/metric-status-card";
+import { SleepQualityGroundedNote } from "@/components/insights/sleep/sleep-quality-grounded-note";
/**
* v1.17.1 — "Sleep quality" block on `/insights/sleep`.
@@ -101,13 +104,6 @@ export function SleepQualitySection({ enabled }: { enabled: boolean }) {
);
if (present.length === 0) return null;
- // v1.18.1 — the quality block is a distinct chart group, so it carries its
- // OWN assessment (matching the canonical "multiple charts ⇒ multiple texts"
- // rule the recovery page meets). Gated on the nightly headline sleep score
- // having data so the generic assessment route only fires when there is a
- // quality series to read.
- const hasSleepScore = (summaries.SLEEP_SCORE?.count ?? 0) > 0;
-
return (
))}
- }
- enabled={hasSleepScore}
- />
+
);
}
+
+/**
+ * The quality block's "Einschätzung". The richer AI narrative wins when it
+ * exists; until then (or forever, on an account with no AI provider) a
+ * deterministic note grounded in the user's own quality averages fills the
+ * slot, so the assessment is never the bare "no analysis yet" line.
+ *
+ * Both reads are gated on the headline sleep score having data — the same gate
+ * the AI route used — so the assessment only surfaces with a quality series to
+ * describe. The grounded note reads the SLEEP_SCORE status query (already
+ * warmed by ``) to know whether the AI text is absent, so it
+ * adds no second round-trip and the two never stack.
+ */
+function SleepQualityAssessmentBlock({
+ summaries,
+}: {
+ summaries: Record;
+}) {
+ const hasSleepScore = (summaries.SLEEP_SCORE?.count ?? 0) > 0;
+ const { data: status, isLoading: statusLoading } = useInsightMetricStatus(
+ "SLEEP_SCORE",
+ hasSleepScore,
+ );
+
+ // The AI narrative is "present" when there is text OR the worker is actively
+ // assembling one (preparing / revalidating). In those cases the AI card owns
+ // the slot. The grounded note only steps in once the status query has SETTLED
+ // with no narrative — so it never flashes ahead of a warm AI assessment.
+ const aiAbsent =
+ !statusLoading &&
+ !status?.text &&
+ status?.preparing !== true &&
+ status?.revalidating !== true;
+
+ return (
+ <>
+ }
+ // Suppress the AI card's static "no analysis yet" / "no provider"
+ // empty states — the grounded note covers that ground with real
+ // content. The AI card still renders its text / preparing states.
+ enabled={hasSleepScore && !aiAbsent}
+ />
+
+ >
+ );
+}
From f453f8a758cb204d2873e9a603c831956a3cea9c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 21:19:17 +0200
Subject: [PATCH 46/71] fix(coach): surface the proactive nudge in the
conversation rail
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The proactive Coach nudge dispatched a notification only — it never
wrote a CoachMessage, so it was invisible under the conversation list
and entirely silent for a user with no push channel configured.
Record the nudge as a real conversation before dispatching: create a
conversation and write the nudge body as the initial assistant message
(encrypted at rest like every other Coach reply), then fire the existing
notification + /coach deep link unchanged. A persistence failure is
caught locally so it can never swallow the notification, and the tick
summary tracks a separate persisted count alongside dispatched.
---
.../ai/coach/__tests__/persistence.test.ts | 74 ++++++++++++++++++-
src/lib/ai/coach/persistence.ts | 54 ++++++++++++++
src/lib/jobs/__tests__/coach-nudge.test.ts | 63 ++++++++++++++++
src/lib/jobs/coach-nudge.ts | 35 +++++++++
src/lib/jobs/reminder/register-status.ts | 1 +
5 files changed, 225 insertions(+), 2 deletions(-)
diff --git a/src/lib/ai/coach/__tests__/persistence.test.ts b/src/lib/ai/coach/__tests__/persistence.test.ts
index f506f5865..dbe6a1afc 100644
--- a/src/lib/ai/coach/__tests__/persistence.test.ts
+++ b/src/lib/ai/coach/__tests__/persistence.test.ts
@@ -1,6 +1,28 @@
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
-import { summariseTitle } from "../persistence";
+// Mock the db + crypto boundaries so `recordProactiveNudge` can be
+// exercised without a real Postgres / encryption key. The transaction
+// runner simply invokes the callback with the stubbed `tx`.
+const txCreate = {
+ coachConversation: { create: vi.fn() },
+ coachMessage: { create: vi.fn() },
+};
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ $transaction: vi.fn(
+ async (cb: (tx: typeof txCreate) => Promise) => cb(txCreate),
+ ),
+ },
+}));
+vi.mock("../bytes-codec", () => ({
+ encryptToBytes: vi.fn((text: string) =>
+ new TextEncoder().encode(`enc:${text}`),
+ ),
+ decryptFromBytes: vi.fn(),
+}));
+
+import { recordProactiveNudge, summariseTitle } from "../persistence";
+import { encryptToBytes } from "../bytes-codec";
describe("summariseTitle", () => {
it("returns the input unchanged when below 80 chars", () => {
@@ -41,3 +63,51 @@ describe("summariseTitle", () => {
expect(summariseTitle(" ")).toBe("New conversation");
});
});
+
+describe("recordProactiveNudge", () => {
+ it("creates a conversation + an encrypted ASSISTANT message in one transaction", async () => {
+ const now = new Date("2026-06-18T05:15:00.000Z");
+ txCreate.coachConversation.create.mockResolvedValue({
+ id: "conv_1",
+ userId: "user_1",
+ title: "Time to weigh in",
+ createdAt: now,
+ updatedAt: now,
+ });
+ txCreate.coachMessage.create.mockResolvedValue({
+ id: "msg_1",
+ createdAt: now,
+ });
+
+ const out = await recordProactiveNudge({
+ userId: "user_1",
+ title: "Time to weigh in",
+ body: "It has been a week — a quick weigh-in keeps the trend honest.",
+ });
+
+ expect(out).toEqual({
+ conversationId: "conv_1",
+ messageId: "msg_1",
+ createdAt: now,
+ });
+
+ // Conversation owned by the user, title summarised.
+ expect(txCreate.coachConversation.create).toHaveBeenCalledWith({
+ data: { userId: "user_1", title: "Time to weigh in" },
+ });
+
+ // The body is encrypted at rest (Bytes), role is assistant, and the
+ // message hangs off the new conversation. No raw plaintext column.
+ const msgArg = txCreate.coachMessage.create.mock.calls[0][0];
+ expect(msgArg.data.conversationId).toBe("conv_1");
+ expect(msgArg.data.role).toBe("assistant");
+ expect(msgArg.data.providerType).toBe("nudge");
+ expect(encryptToBytes).toHaveBeenCalledWith(
+ "It has been a week — a quick weigh-in keeps the trend honest.",
+ );
+ // The stored column is the ciphertext, never the plaintext body.
+ expect(new TextDecoder().decode(msgArg.data.encryptedContent)).toContain(
+ "enc:",
+ );
+ });
+});
diff --git a/src/lib/ai/coach/persistence.ts b/src/lib/ai/coach/persistence.ts
index 9d0c0440c..7e318ee93 100644
--- a/src/lib/ai/coach/persistence.ts
+++ b/src/lib/ai/coach/persistence.ts
@@ -188,6 +188,60 @@ export async function appendMessage(
};
}
+export interface RecordProactiveNudgeParams {
+ userId: string;
+ /** Conversation title (the nudge headline) — summarised to ≤80 chars. */
+ title: string;
+ /** The nudge body, persisted as the initial ASSISTANT message. */
+ body: string;
+ /** Trigger tag for the wide-event annotation on the caller side. */
+ providerType?: string | null;
+}
+
+/**
+ * v1.18.6 (CCH-02) — record a proactive Coach nudge as a real
+ * conversation so it shows up in the conversation rail regardless of
+ * which push channel (if any) the user configured. The proactive cron
+ * used to dispatch a notification ONLY; with no push channel the nudge
+ * was entirely invisible.
+ *
+ * Creates a fresh conversation and writes the nudge as the initial
+ * ASSISTANT message in one transaction so a partial write never leaves
+ * an empty thread in the rail. The body crosses the same
+ * `encryptToBytes` boundary as every other Coach message, so the nudge
+ * text is encrypted at rest like a normal reply. Returns the new
+ * conversation + message ids for the caller's annotation.
+ */
+export async function recordProactiveNudge(
+ params: RecordProactiveNudgeParams,
+): Promise<{ conversationId: string; messageId: string; createdAt: Date }> {
+ return prisma.$transaction(async (tx) => {
+ const conversation = await tx.coachConversation.create({
+ data: {
+ userId: params.userId,
+ title: summariseTitle(params.title),
+ },
+ });
+ const message = await tx.coachMessage.create({
+ data: {
+ conversationId: conversation.id,
+ role: "assistant",
+ encryptedContent: encryptToBytes(params.body),
+ metricSourceJson: null,
+ providerType: params.providerType ?? "nudge",
+ promptVersion: null,
+ },
+ });
+ // The conversation's `createdAt` == `updatedAt` on creation, so the
+ // rail already orders it to the top; no extra `update` needed.
+ return {
+ conversationId: conversation.id,
+ messageId: message.id,
+ createdAt: message.createdAt,
+ };
+ });
+}
+
/**
* Fetch one conversation + every message, decrypting each body on
* read. Returns null when the conversation does not exist OR when the
diff --git a/src/lib/jobs/__tests__/coach-nudge.test.ts b/src/lib/jobs/__tests__/coach-nudge.test.ts
index a417e359b..dfa3a5a23 100644
--- a/src/lib/jobs/__tests__/coach-nudge.test.ts
+++ b/src/lib/jobs/__tests__/coach-nudge.test.ts
@@ -714,4 +714,67 @@ describe("runCoachNudgeTick — gates and prefs", () => {
"You wanted to keep an eye on: morning blood pressure.",
);
});
+
+ it("persists the nudge as a conversation before dispatching (CCH-02)", async () => {
+ const order: string[] = [];
+ const recordNudge = vi.fn(
+ async (args: { userId: string; title: string; body: string }) => {
+ void args;
+ order.push("persist");
+ return {
+ conversationId: "conv_1",
+ messageId: "msg_1",
+ createdAt: now,
+ };
+ },
+ );
+ const dispatch = vi.fn(async () => {
+ order.push("dispatch");
+ return { dispatched: true };
+ });
+ const prisma = prismaMock({
+ users: [userRow(null)],
+ intakeRows: failingIntakes,
+ });
+ const summary = await runCoachNudgeTick(
+ prisma as unknown as PrismaClient,
+ now,
+ { dispatch: dispatch as never, recordNudge: recordNudge as never },
+ );
+ expect(summary.dispatched).toBe(1);
+ expect(summary.persisted).toBe(1);
+ // The conversation must be written BEFORE the notification fires so
+ // the rail always carries the nudge even if the push channel is gone.
+ expect(order).toEqual(["persist", "dispatch"]);
+ const persistArg = recordNudge.mock.calls[0]?.[0] as unknown as {
+ userId: string;
+ title: string;
+ body: string;
+ };
+ expect(persistArg.userId).toBe("user-1");
+ // The persisted body is the same compliance nudge copy the
+ // notification carries — it lands as the initial assistant message.
+ expect(persistArg.body).toContain("Your intakes were patchy this week");
+ expect(persistArg.title.length).toBeGreaterThan(0);
+ });
+
+ it("still dispatches + counts the nudge when persistence throws", async () => {
+ const recordNudge = vi.fn(async () => {
+ throw new Error("db down");
+ });
+ const dispatch = vi.fn(async () => ({ dispatched: true }));
+ const prisma = prismaMock({
+ users: [userRow(null)],
+ intakeRows: failingIntakes,
+ });
+ const summary = await runCoachNudgeTick(
+ prisma as unknown as PrismaClient,
+ now,
+ { dispatch: dispatch as never, recordNudge: recordNudge as never },
+ );
+ // A persistence failure must not swallow the notification.
+ expect(dispatch).toHaveBeenCalledTimes(1);
+ expect(summary.dispatched).toBe(1);
+ expect(summary.persisted).toBe(0);
+ });
});
diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts
index 8c5ecb03f..d1fe2724e 100644
--- a/src/lib/jobs/coach-nudge.ts
+++ b/src/lib/jobs/coach-nudge.ts
@@ -73,6 +73,7 @@ import {
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import { resolveCoachNudgePrefs } from "@/lib/validations/notification-prefs";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
+import { recordProactiveNudge } from "@/lib/ai/coach/persistence";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { Locale } from "@/lib/i18n/config";
import { defaultLocale, locales } from "@/lib/i18n/config";
@@ -167,6 +168,13 @@ export const COACH_NUDGE_TRIGGER_GROUPS: Record<
export interface CoachNudgeSummary {
candidatesScanned: number;
dispatched: number;
+ /**
+ * v1.18.6 (CCH-02) — nudges written into the conversation rail as an
+ * initial ASSISTANT message. Counted independently of `dispatched`:
+ * the conversation lands even when no push channel is configured, so
+ * `persisted` can exceed `dispatched`.
+ */
+ persisted: number;
skippedOptedOut: number;
skippedNoProvider: number;
skippedRecentNudge: number;
@@ -695,13 +703,16 @@ export async function runCoachNudgeTick(
now: Date,
options: {
dispatch?: typeof dispatchNotification;
+ recordNudge?: typeof recordProactiveNudge;
} = {},
): Promise {
const dispatchImpl = options.dispatch ?? dispatchNotification;
+ const recordNudgeImpl = options.recordNudge ?? recordProactiveNudge;
const summary: CoachNudgeSummary = {
candidatesScanned: 0,
dispatched: 0,
+ persisted: 0,
skippedOptedOut: 0,
skippedNoProvider: 0,
skippedRecentNudge: 0,
@@ -839,6 +850,30 @@ export async function runCoachNudgeTick(
user.locale,
coachFocus,
);
+
+ // v1.18.6 (CCH-02) — persist the nudge as a real conversation
+ // BEFORE dispatching the notification. The proactive nudge used to
+ // be notification-only, so a user with no push channel never saw
+ // it anywhere; writing it as the initial ASSISTANT message means it
+ // always lands in the conversation rail (and clears the FAB unread
+ // dot once the user opens the Coach). A persistence failure must
+ // not swallow the notification, so it is caught locally and the
+ // dispatch still fires.
+ try {
+ await recordNudgeImpl({
+ userId: user.id,
+ title,
+ body,
+ });
+ summary.persisted += 1;
+ } catch (persistErr: unknown) {
+ const message =
+ persistErr instanceof Error ? persistErr.message : String(persistErr);
+ getEvent()?.addWarning(
+ `coach-nudge conversation persist failed for ${user.id}: ${message}`,
+ );
+ }
+
const outcome = await dispatchImpl({
eventType: "COACH_NUDGE",
userId: user.id,
diff --git a/src/lib/jobs/reminder/register-status.ts b/src/lib/jobs/reminder/register-status.ts
index ee7d1b900..0d5a7ab67 100644
--- a/src/lib/jobs/reminder/register-status.ts
+++ b/src/lib/jobs/reminder/register-status.ts
@@ -306,6 +306,7 @@ export async function registerStatusQueues(
result: {
candidates_scanned: summary.candidatesScanned,
dispatched: summary.dispatched,
+ persisted: summary.persisted,
skipped_opted_out: summary.skippedOptedOut,
skipped_no_provider: summary.skippedNoProvider,
skipped_recent_nudge: summary.skippedRecentNudge,
From 3a3f88a80c6df0cea948821ca2ab79bab7005789 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 21:19:27 +0200
Subject: [PATCH 47/71] feat(coach): mark an unread proactive nudge on the
Coach launcher
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add a discreet unread dot to the Coach FAB when the Coach has something
the user has not opened yet, and clear it when they open the Coach.
The signal is server-authoritative: a new coach_last_seen_at column on
users records the last time the Coach was opened, and the launcher's
status endpoint reports unread when the newest assistant message is
newer than that stamp — so the dot works even with no push channel and
clears across every device, web and iOS. Opening the Coach (FAB or the
/coach page) stamps the column via a new /api/insights/coach/seen
endpoint; the launcher keeps a local stamp only for an instant paint.
The dot is a calm cyan, not an alarming red, per the card-status rule.
---
.../0175_v1186_coach_last_seen/migration.sql | 15 ++++
prisma/schema.prisma | 12 +++
.../api/insights/coach/nudge-status/route.ts | 55 +++++++-----
src/app/api/insights/coach/seen/route.ts | 33 +++++++
src/components/insights/layout-coach-fab.tsx | 89 +++++++++++++++----
src/lib/query-keys/coach.ts | 2 +
6 files changed, 165 insertions(+), 41 deletions(-)
create mode 100644 prisma/migrations/0175_v1186_coach_last_seen/migration.sql
create mode 100644 src/app/api/insights/coach/seen/route.ts
diff --git a/prisma/migrations/0175_v1186_coach_last_seen/migration.sql b/prisma/migrations/0175_v1186_coach_last_seen/migration.sql
new file mode 100644
index 000000000..c784fd21b
--- /dev/null
+++ b/prisma/migrations/0175_v1186_coach_last_seen/migration.sql
@@ -0,0 +1,15 @@
+-- v1.18.6 (CCH-03) — Coach "last seen" stamp for the FAB unread dot.
+--
+-- A single nullable timestamp on `users`. The proactive Coach nudge now
+-- lands as an ASSISTANT message in the conversation rail (CCH-02); the
+-- Coach FAB shows a discreet unread dot when the newest Coach message is
+-- newer than this stamp, and clears it once the user opens the Coach
+-- (the open writes the current time here via POST
+-- /api/insights/coach/seen).
+--
+-- NULL backfills every existing row to "never opened" — harmless: the
+-- dot only paints when an actual newer Coach message exists, so a user
+-- with no proactive nudge yet sees nothing. No data migration needed.
+
+ALTER TABLE "users"
+ ADD COLUMN "coach_last_seen_at" TIMESTAMP(3);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index f87097c6e..bb3b65d1d 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -453,6 +453,18 @@ model User {
// `GET /api/auth/me` as a `modules: { : boolean }` map.
modulePreferencesJson Json? @map("module_preferences_json")
+ // v1.18.6 (CCH-03) — "Coach last seen" stamp powering the unread dot
+ // on the Coach FAB. The proactive nudge now lands as an ASSISTANT
+ // message in the conversation rail (CCH-02); the FAB shows a discreet
+ // unread dot when the newest Coach message is newer than this stamp,
+ // and clears it when the user opens the Coach (drawer or page, which
+ // writes the current time here via `POST /api/insights/coach/seen`).
+ // Null = never opened the Coach, so any existing message reads as
+ // unread once. Server-authoritative so the signal is consistent across
+ // web + iOS; the FAB keeps a local mirror only as an instant-paint
+ // optimisation.
+ coachLastSeenAt DateTime? @map("coach_last_seen_at")
+
// v1.5.5 — self-hosted avatar storage.
//
// Replaces the Gravatar third-party leak (the old `getGravatarUrl`
diff --git a/src/app/api/insights/coach/nudge-status/route.ts b/src/app/api/insights/coach/nudge-status/route.ts
index a4aad8dd6..48d9d58c6 100644
--- a/src/app/api/insights/coach/nudge-status/route.ts
+++ b/src/app/api/insights/coach/nudge-status/route.ts
@@ -1,18 +1,26 @@
/**
- * GET /api/insights/coach/nudge-status — has the proactive Coach nudge
- * something the caller has not engaged with yet?
+ * GET /api/insights/coach/nudge-status — is there an unread Coach
+ * message the caller has not opened yet?
*
- * v1.16.1 — feeds the floating Coach bubble: the bubble renders only
- * while an unseen Coach-initiated nudge exists, instead of being a
- * permanent FAB. "Unseen" is derived, not stored: the latest successful
- * `COACH_NUDGE` dispatch (the `push_attempts` ledger the nudge cron
- * already anchors its frequency cap on) counts as read once the user
- * has sent a Coach message after it. The client additionally remembers
- * a local "seen" stamp so opening the chat without sending also clears
- * the bubble on that device.
+ * v1.18.6 (CCH-03) — the proactive Coach nudge now lands as a real
+ * ASSISTANT message in the conversation rail (CCH-02), not as a
+ * notification-only dispatch. The unread signal moved with it: instead
+ * of anchoring on the `push_attempts` ledger (which is empty when no
+ * push channel is configured, so the nudge was invisible), the status
+ * compares the newest Coach assistant message against the
+ * server-authoritative `User.coachLastSeenAt` stamp.
*
- * No new table, no migration: both timestamps come from existing rows,
- * scoped to the caller.
+ * `unread` is true when an assistant message exists that is newer than
+ * the last time the user opened the Coach (drawer or page, which writes
+ * `coachLastSeenAt` via `POST /api/insights/coach/seen`). A user who has
+ * never opened the Coach reads any existing nudge as unread exactly
+ * once. Server-authoritative so the signal is consistent across web +
+ * iOS; the FAB keeps a local mirror only as an instant-paint
+ * optimisation.
+ *
+ * `nudgedAt` carries the newest assistant-message timestamp so the FAB's
+ * local seen-stamp keys on a stable value (kept for the existing client
+ * contract).
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
@@ -23,23 +31,26 @@ export const GET = apiHandler(async () => {
const { user } = await requireAuth();
await requireAssistantSurface("coach");
- const [lastNudge, lastUserMessage] = await Promise.all([
- prisma.pushAttempt.findFirst({
- where: { userId: user.id, eventType: "COACH_NUDGE", result: "ok" },
+ // The newest assistant message across the caller's conversations — the
+ // proactive nudge writes one, and so does every normal Coach reply. We
+ // intentionally key on assistant (not user) messages: the unread dot
+ // means "the Coach said something", and the open-stamp clears it.
+ const [lastAssistant, seen] = await Promise.all([
+ prisma.coachMessage.findFirst({
+ where: { role: "assistant", conversation: { userId: user.id } },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
- prisma.coachMessage.findFirst({
- where: { role: "user", conversation: { userId: user.id } },
- orderBy: { createdAt: "desc" },
- select: { createdAt: true },
+ prisma.user.findUnique({
+ where: { id: user.id },
+ select: { coachLastSeenAt: true },
}),
]);
- const nudgedAt = lastNudge?.createdAt ?? null;
+ const nudgedAt = lastAssistant?.createdAt ?? null;
+ const lastSeenAt = seen?.coachLastSeenAt ?? null;
const unread =
- nudgedAt !== null &&
- (lastUserMessage === null || lastUserMessage.createdAt < nudgedAt);
+ nudgedAt !== null && (lastSeenAt === null || lastSeenAt < nudgedAt);
return apiSuccess({
nudgedAt: nudgedAt ? nudgedAt.toISOString() : null,
diff --git a/src/app/api/insights/coach/seen/route.ts b/src/app/api/insights/coach/seen/route.ts
new file mode 100644
index 000000000..603edafb7
--- /dev/null
+++ b/src/app/api/insights/coach/seen/route.ts
@@ -0,0 +1,33 @@
+/**
+ * POST /api/insights/coach/seen — mark every Coach message as read.
+ *
+ * v1.18.6 (CCH-03) — opening the Coach (drawer or full page) clears the
+ * unread dot on the FAB. The open stamps `User.coachLastSeenAt = now`;
+ * the FAB's `GET /api/insights/coach/nudge-status` then reads no
+ * assistant message newer than the stamp and drops the dot. Server-
+ * authoritative so the cleared state follows the user across web + iOS,
+ * not just the device that opened the Coach.
+ *
+ * No request body — the timestamp is server-minted from `now()` so a
+ * client can never backdate the stamp to suppress a future nudge.
+ */
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiSuccess } from "@/lib/api-response";
+import { prisma } from "@/lib/db";
+import { requireAssistantSurface } from "@/lib/feature-flags";
+
+export const POST = apiHandler(async () => {
+ const { user } = await requireAuth();
+ await requireAssistantSurface("coach");
+
+ const seenAt = new Date();
+ await prisma.user.update({
+ where: { id: user.id },
+ data: { coachLastSeenAt: seenAt },
+ });
+
+ return apiSuccess({ seenAt: seenAt.toISOString() });
+});
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx
index 0be23a4c4..ac0c877e6 100644
--- a/src/components/insights/layout-coach-fab.tsx
+++ b/src/components/insights/layout-coach-fab.tsx
@@ -2,7 +2,7 @@
import { useEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
-import { useQuery } from "@tanstack/react-query";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -13,18 +13,26 @@ import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
import { queryKeys } from "@/lib/query-keys";
-import { apiGet } from "@/lib/api/api-fetch";
+import { apiGet, apiPost } from "@/lib/api/api-fetch";
/**
* v1.16.8 — the Coach FAB is a permanent launcher again, now on EVERY
* authenticated page (mounted once in ``, no longer scoped
* to `/insights/**`). The v1.16.1 nudge-only bubble folded into it: the
- * FAB always renders, and an unseen proactive `COACH_NUDGE` (surfaced
+ * FAB always renders, and an unread proactive Coach message (surfaced
* by `/api/insights/coach/nudge-status`) paints a small unread dot on
- * its corner instead of toggling the whole button. The dot clears once
- * the nudge counts as read — the user sent a Coach message after the
- * nudge (server-derived), or opened the Coach on this device (local
- * seen stamp keyed by the nudge timestamp).
+ * its corner instead of toggling the whole button.
+ *
+ * v1.18.6 (CCH-02/CCH-03) — the proactive nudge now lands as a real
+ * ASSISTANT message in the conversation rail, not a notification-only
+ * dispatch, so the unread signal moved onto the server-authoritative
+ * `User.coachLastSeenAt` stamp: `status.unread` is true while a Coach
+ * message is newer than the last time the user opened the Coach. Opening
+ * the Coach (this FAB, or the `/coach` page) fires
+ * `POST /api/insights/coach/seen`, which stamps the server and clears
+ * the dot everywhere (web + iOS). A local "seen" stamp is kept only as
+ * an instant-paint optimisation so the dot disappears the moment the
+ * user taps, before the mutation round-trips.
*
* The FAB hides inside the Coach view itself (`/coach`) — a
* launcher pointing at the page the user is already on is noise.
@@ -44,9 +52,12 @@ export interface CoachNudgeStatus {
}
/**
- * Pure unread derivation — server signal AND-ed with the local
- * seen-stamp. Exported so the unit test pins the contract without a
- * QueryClient round-trip.
+ * Pure unread derivation — the server signal is authoritative
+ * (`status.unread` already compares the newest Coach message against the
+ * persisted `coachLastSeenAt`). The local seen-stamp only suppresses the
+ * dot for an instant after the user taps, before the mark-seen mutation
+ * + status refetch land. Exported so the unit test pins the contract
+ * without a QueryClient round-trip.
*/
export function isNudgeUnread(
status: CoachNudgeStatus | undefined,
@@ -77,6 +88,7 @@ export function LayoutCoachFab() {
const coachAvailable = !!launch && flags.coach && !disableCoach;
const onCoachPage = pathname?.startsWith("/coach") ?? false;
+ const queryClient = useQueryClient();
const { data: status } = useQuery({
queryKey: queryKeys.coachNudgeStatus(),
@@ -87,6 +99,22 @@ export function LayoutCoachFab() {
staleTime: 5 * 60 * 1000,
});
+ // v1.18.6 (CCH-03) — stamp `coachLastSeenAt` server-side when the user
+ // opens the Coach so the dot clears across every device. Fire-and-
+ // forget from the UI's perspective (the local seen-stamp already
+ // suppressed the dot); on success the status query refetches so the
+ // server-authoritative `unread` re-resolves to false.
+ const markSeen = useMutation({
+ mutationKey: queryKeys.coachMarkSeen(),
+ mutationFn: async () =>
+ apiPost<{ seenAt: string }>("/api/insights/coach/seen", {}),
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.coachNudgeStatus(),
+ });
+ },
+ });
+
const nudgedAt = status?.nudgedAt ?? null;
const unread = isNudgeUnread(status, seenStamp);
@@ -107,16 +135,30 @@ export function LayoutCoachFab() {
prevUnreadRef.current = unread;
}, [unread, t]);
- // Visiting the Coach page itself counts as reading the nudge on this
- // device — persist the stamp so the dot stays gone after leaving.
+ // Visiting the Coach page itself counts as reading the nudge — persist
+ // the local instant-paint stamp AND stamp the server so the dot stays
+ // gone after leaving, on every device. Fired once per page entry: the
+ // ref guards against the effect re-running on unrelated re-renders.
+ const seenStampedRef = useRef(false);
useEffect(() => {
- if (!onCoachPage || nudgedAt === null) return;
- try {
- window.localStorage.setItem(NUDGE_SEEN_STORAGE_KEY, nudgedAt);
- } catch {
- // Storage unavailable (private mode) — the server-side read
- // signal still clears the dot once the user sends a message.
+ if (!onCoachPage) {
+ seenStampedRef.current = false;
+ return;
}
+ if (seenStampedRef.current) return;
+ seenStampedRef.current = true;
+ if (nudgedAt !== null) {
+ try {
+ window.localStorage.setItem(NUDGE_SEEN_STORAGE_KEY, nudgedAt);
+ } catch {
+ // Storage unavailable (private mode) — the server stamp below
+ // still clears the dot once it round-trips.
+ }
+ }
+ markSeen.mutate();
+ // `markSeen` is a stable mutation object; excluding it keeps the
+ // effect from re-firing on its internal state changes.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [onCoachPage, nudgedAt]);
if (!coachAvailable) return null;
@@ -136,6 +178,10 @@ export function LayoutCoachFab() {
// Best effort — see above.
}
}
+ // v1.18.6 (CCH-03) — stamp the server so the dot clears on every
+ // device, not just this one. The local stamp above already hid the
+ // dot for an instant paint; the mutation makes it durable.
+ if (unread) markSeen.mutate();
// v1.16.11 — open the side drawer in place (the launch context the
// layout mount consumes) instead of navigating to the chat page;
// the conversation arrives without losing the page underneath.
@@ -194,10 +240,15 @@ export function LayoutCoachFab() {
>
{unread ? (
+ // v1.18.6 (CCH-03) — discreet "the Coach said something" dot.
+ // Deliberately NOT an alarming red (the medication-card rule:
+ // status via calm signal, never an alarm tint): a small cyan
+ // dot ringed against the FAB background reads as informative,
+ // not urgent.
) : null}
diff --git a/src/lib/query-keys/coach.ts b/src/lib/query-keys/coach.ts
index e759d3342..61ffa23a2 100644
--- a/src/lib/query-keys/coach.ts
+++ b/src/lib/query-keys/coach.ts
@@ -11,4 +11,6 @@ export const coachKeys = {
coachAboutMeQuestions: () => ["coach-about-me", "questions"] as const,
/** v1.16.1 — unseen proactive-nudge state for the floating Coach bubble. */
coachNudgeStatus: () => ["coach-nudge-status"] as const,
+ /** v1.18.6 (CCH-03) — mark-Coach-seen mutation (clears the FAB unread dot). */
+ coachMarkSeen: () => ["coach-mark-seen"] as const,
};
From 96c564178718ad434f822bad519dc85bfd810453 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 21:19:36 +0200
Subject: [PATCH 48/71] fix(coach): give the chat page more width and fix the
rail scrollbar
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Coach page read as cut off against other pages and the left
conversation scrollbar looked broken.
Width: the page sat inside the shared max-w-screen-xl content frame with
a tall pb-20 FAB gutter that does nothing on /coach (the FAB hides
there). Break out of the frame so the chat claims the full content width
and viewport height, edge-to-edge on small screens and a contained card
from sm up, and centre the thread on a comfortable reading measure.
Scrollbar: the page-surface history rail was wrapped in an overflow-auto
container while the rail itself is a flex column with its own
overflow-auto list — two nested scroll regions. The inner list's height
never resolved, so its scrollbar rendered against an indeterminate track
and the search field scrolled away. Drop the outer scroll: the rail
keeps a fixed search header and one internal list-scroll.
---
src/app/coach/page.tsx | 39 ++++++++++++++-----
.../coach-panel/coach-drawer-body.tsx | 13 ++++++-
.../insights/coach-panel/message-thread.tsx | 7 ++++
3 files changed, 49 insertions(+), 10 deletions(-)
diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx
index d8c0d5402..9ffb5a694 100644
--- a/src/app/coach/page.tsx
+++ b/src/app/coach/page.tsx
@@ -63,15 +63,35 @@ export default function CoachPage() {
`'s shared `mx-auto max-w-screen-xl px-4
+ // pt-6 pb-20 md:px-6` content frame, which boxed the chat into the
+ // same narrow column every dashboard card uses and left a tall dead
+ // band below the card (the `pb-20` FAB gutter, pointless here since
+ // the FAB hides on `/coach`). Break out of that frame: negative
+ // margins cancel the container's horizontal + top/bottom padding so
+ // the chat claims the full content width and the viewport height,
+ // then a light inner padding keeps it off the window edge. The card
+ // itself drops its rounded border on small screens (edge-to-edge,
+ // app-like) and keeps the contained card look from `sm+`.
+ //
+ // v1.18.1 (W-COACH-UI C1/C3) — the card formerly capped at
+ // `100dvh-13rem`, leaving wasted space below it; we now fill the
+ // reclaimed height.
+ className={
+ // Cancel the AuthShell container's padding (`-mt-6` top,
+ // `-mb-20` bottom, `-mx-4 md:-mx-6` sides) so the surface is
+ // full-bleed, then re-pad lightly. The height is the full
+ // viewport minus the top bar (`4rem`) and the small gutters.
+ "-mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-4rem)] min-h-[32rem] flex-col md:-mx-6 " +
+ "px-2 pt-2 pb-3 sm:px-4 sm:pt-4 sm:pb-4"
+ }
>
+
>
}
- />
+ />
+
);
}
diff --git a/src/components/insights/coach-panel/coach-drawer-body.tsx b/src/components/insights/coach-panel/coach-drawer-body.tsx
index f59461b02..1c677b86f 100644
--- a/src/components/insights/coach-panel/coach-drawer-body.tsx
+++ b/src/components/insights/coach-panel/coach-drawer-body.tsx
@@ -78,7 +78,18 @@ export function CoachDrawerBody({
{t("insights.coach.historyTitle")}
-
{historyRail}
+ {/* v1.18.6 (CCH-01) — the wrapping `overflow-y-auto` div here
+ was the broken left scrollbar: `` is itself a
+ `flex h-full flex-col` with its OWN `overflow-y-auto` list,
+ so this nested it inside a second scroll container. The
+ outer container scrolled the whole rail (search field
+ included) against an `h-full` child whose height resolved to
+ its content, leaving the inner list's `flex-1 min-h-0`
+ without a bounded track — the scrollbar rendered against an
+ indeterminate height and read as half-broken. Drop the outer
+ scroll: the rail keeps a fixed search header and its single
+ internal list-scroll is the only scroll region. */}
+
{historyRail}
)}
diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx
index 75a632f59..6dae240d0 100644
--- a/src/components/insights/coach-panel/message-thread.tsx
+++ b/src/components/insights/coach-panel/message-thread.tsx
@@ -363,6 +363,13 @@ export function MessageThread({
data-slot="coach-message-thread"
className={cn(
"flex h-full flex-col gap-4 overflow-y-auto px-4 py-4 sm:px-6",
+ // v1.18.6 (CCH-01) — on the now full-width page surface a thread
+ // stretched edge-to-edge sprawled the prose to an unreadable
+ // measure. Centre the content on a comfortable max width (Claude-
+ // like) via an `mx-auto` inner gutter; the scrollbar still rides
+ // the surface edge. The drawer surface is already narrow, so the
+ // cap only bites on the wide page surface.
+ "[&>*]:mx-auto [&>*]:w-full [&>*]:max-w-3xl",
"scroll-smooth",
)}
>
From 2535d45fc76f2049feeacd8b01e63dfa8c6c7e21 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 21:19:42 +0200
Subject: [PATCH 49/71] refactor(insights): drop the redundant per-metric Coach
launcher
Every metric sub-page header carried a Coach launch icon. With the Coach
FAB anchored bottom-right on every authenticated page, that header icon
was a second entry point to the same surface. Remove it from the shared
sub-page shell so the FAB is the single Coach launcher app-wide. The
coachLaunch prop stays accepted on the shell (the metric pages still pass
it) but no longer renders a control, so no per-page churn is needed.
---
src/components/insights/sub-page-shell.tsx | 26 +++++++++++++---------
1 file changed, 16 insertions(+), 10 deletions(-)
diff --git a/src/components/insights/sub-page-shell.tsx b/src/components/insights/sub-page-shell.tsx
index ba30ea838..d658f3de9 100644
--- a/src/components/insights/sub-page-shell.tsx
+++ b/src/components/insights/sub-page-shell.tsx
@@ -13,7 +13,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
-import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
import { TargetAdjustButton } from "@/components/insights/target-adjust-button";
import { TargetAdjustProvider } from "@/lib/insights/target-adjust-context";
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
@@ -108,13 +107,12 @@ export interface SubPageShellProps {
/**
* v1.8.6 — opt-in Coach launch in the page header.
*
- * When true, the shell mounts an icon-only `` at
- * the top-right of the header, vertically aligned with the heading, so
- * "Coach fragen" reads as a header action rather than a foot-of-page
- * button. Every routed category page passes it; the mother page and
- * empty-state branches omit it. The button self-gates on the Coach
- * feature flag + per-user opt-out, so a disabled-Coach tenant shows
- * nothing.
+ * v1.18.6 (CCH-04) — RETIRED as a rendered control. With the Coach FAB
+ * anchored bottom-right on every authenticated page, the per-metric
+ * Coach icon was a redundant second entry point, so the shell no longer
+ * paints anything for it. The prop stays on the signature (every routed
+ * category page still passes it) to avoid a churny rename across ~14
+ * call sites; it is intentionally inert.
*/
coachLaunch?: boolean;
/**
@@ -139,7 +137,10 @@ export function SubPageShell({
focusOnMount = false,
statStrip,
diversityNudge,
- coachLaunch = false,
+ // v1.18.6 (CCH-04) — `coachLaunch` is intentionally not destructured:
+ // the prop is still accepted on the type (call sites pass it) but the
+ // shell no longer renders a Coach control. The FAB is the single Coach
+ // entry point app-wide.
showAllValuesType,
children,
}: SubPageShellProps) {
@@ -270,7 +271,12 @@ export function SubPageShell({
) : null}
- {coachLaunch ? : null}
+ {/* v1.18.6 (CCH-04) — the per-metric Coach launch icon is
+ gone. With the Coach FAB anchored bottom-right on every
+ authenticated page, a second Coach affordance in each
+ metric header was redundant. The `coachLaunch` prop is
+ still accepted (every metric page passes it) but no longer
+ paints a control — the FAB is the single Coach entry. */}
{/* v1.16.8 — the v1.16.4 customise cog is gone: the sticky
tab strip above already carries the same control on every
insights surface, so the header copy was a duplicate. */}
From 71b29f5b6f2fb18ba33040e261978aeb4a29a6ba Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 21:20:19 +0200
Subject: [PATCH 50/71] refactor(recovery): drop the per-group sub-headings on
the recovery page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Read next to the page's "Erholung" heading, the "Recharge" / "Strain & load" /
"Daily heart & energy" sub-headings were redundant chrome. Remove them and
render the present signal blocks as one flat, evenly-spaced stack — each block
already carries its own title + explainer, so nothing loses context. The group
structure stays internally to drive block order and the data gate. Promote the
block title to an h2 so the page heading outline stays nested (h1 then h2) with
no skipped level, and drop the now-orphaned group i18n keys.
---
messages/de.json | 12 ---
messages/en.json | 12 ---
messages/es.json | 12 ---
messages/fr.json | 12 ---
messages/it.json | 12 ---
messages/pl.json | 12 ---
.../__tests__/device-score-surfaces.test.tsx | 13 ++-
.../recovery/recovery-metric-block.tsx | 12 +--
.../insights/recovery/recovery-section.tsx | 82 ++++++-------------
9 files changed, 40 insertions(+), 139 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index 8f654ac76..6e9a48271 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -3509,18 +3509,6 @@
"description": "Wie gut sich dein Körper erholt hat – die Erholungs-, Belastungs- und Load-Signale, die dein Wearable über Nacht aufzeichnet.",
"emptyTitle": "Noch keine Erholungsdaten",
"empty": "Noch keine Erholungsdaten. Verbinde ein WHOOP-, Polar- oder Oura-Konto, dann füllen sich diese Werte, sobald dein Gerät synchronisiert.",
- "recharge": {
- "title": "Recharge",
- "subtitle": "Ladung des autonomen Nervensystems"
- },
- "strain": {
- "title": "Belastung & Load",
- "subtitle": "Tägliche kardiovaskuläre Belastung"
- },
- "cardio": {
- "title": "Herz & Energie pro Tag",
- "subtitle": "Herz- und Energiewerte des ganzen Tages"
- },
"block": {
"ansChargeExplainer": "Wie aufgeladen dein autonomes Nervensystem in den Tag startet – ein Recharge-Wert aus deinen nächtlichen Herz- und Atemsignalen.",
"dayStrainExplainer": "Die kardiovaskuläre Tagesbelastung auf der Belastungsskala deines Geräts. Lies sie im Verhältnis zu deiner Erholung, nicht als Ziel für sich.",
diff --git a/messages/en.json b/messages/en.json
index accbf337f..c33810296 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -3509,18 +3509,6 @@
"description": "How well your body has bounced back — the recovery, strain and load signals your wearable records overnight.",
"emptyTitle": "No recovery data yet",
"empty": "No recovery data yet. Connect a WHOOP, Polar or Oura account and these scores fill in once your device syncs.",
- "recharge": {
- "title": "Recharge",
- "subtitle": "Autonomic-nervous-system charge"
- },
- "strain": {
- "title": "Strain & load",
- "subtitle": "Daily cardiovascular load"
- },
- "cardio": {
- "title": "Daily heart & energy",
- "subtitle": "Whole-day cardiac and energy totals"
- },
"block": {
"ansChargeExplainer": "How charged your autonomic nervous system is for the day ahead — a recharge reading drawn from your overnight heart and breathing signals.",
"dayStrainExplainer": "The day's whole-body cardiovascular load on your device's strain scale. Read it against your recovery, not as a goal on its own.",
diff --git a/messages/es.json b/messages/es.json
index 9fa3e2b1e..0a1b03839 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -3509,18 +3509,6 @@
"description": "Cómo se ha recuperado tu cuerpo: las señales de recuperación, esfuerzo y carga que tu wearable registra durante la noche.",
"emptyTitle": "Aún no hay datos de recuperación",
"empty": "Aún no hay datos de recuperación. Conecta una cuenta de WHOOP, Polar u Oura y estas puntuaciones se completarán cuando tu dispositivo sincronice.",
- "recharge": {
- "title": "Recarga",
- "subtitle": "Carga del sistema nervioso autónomo"
- },
- "strain": {
- "title": "Esfuerzo y carga",
- "subtitle": "Carga cardiovascular diaria"
- },
- "cardio": {
- "title": "Corazón y energía del día",
- "subtitle": "Totales cardiacos y de energía de todo el día"
- },
"block": {
"ansChargeExplainer": "Cuán cargado está tu sistema nervioso autónomo para el día — una lectura de recarga obtenida de tus señales nocturnas de corazón y respiración.",
"dayStrainExplainer": "La carga cardiovascular del día en la escala de esfuerzo de tu dispositivo. Léela en relación con tu recuperación, no como una meta en sí.",
diff --git a/messages/fr.json b/messages/fr.json
index 9c1a433c0..38a840ea8 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -3509,18 +3509,6 @@
"description": "À quel point votre corps a récupéré : les signaux de récupération, de charge et d'effort que votre wearable enregistre la nuit.",
"emptyTitle": "Pas encore de données de récupération",
"empty": "Pas encore de données de récupération. Connectez un compte WHOOP, Polar ou Oura et ces scores se rempliront dès que votre appareil se synchronise.",
- "recharge": {
- "title": "Recharge",
- "subtitle": "Charge du système nerveux autonome"
- },
- "strain": {
- "title": "Effort et charge",
- "subtitle": "Charge cardiovasculaire quotidienne"
- },
- "cardio": {
- "title": "Cœur et énergie du jour",
- "subtitle": "Totaux cardiaques et énergétiques de la journée"
- },
"block": {
"ansChargeExplainer": "À quel point votre système nerveux autonome est rechargé pour la journée — une mesure de recharge issue de vos signaux nocturnes de cœur et de respiration.",
"dayStrainExplainer": "La charge cardiovasculaire de la journée sur l'échelle d'effort de votre appareil. Lisez-la au regard de votre récupération, pas comme un objectif en soi.",
diff --git a/messages/it.json b/messages/it.json
index 77ba27740..489c26670 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -3509,18 +3509,6 @@
"description": "Quanto bene il tuo corpo si è ripreso: i segnali di recupero, sforzo e carico che il tuo wearable registra durante la notte.",
"emptyTitle": "Ancora nessun dato di recupero",
"empty": "Ancora nessun dato di recupero. Collega un account WHOOP, Polar o Oura e questi punteggi si popoleranno appena il dispositivo si sincronizza.",
- "recharge": {
- "title": "Ricarica",
- "subtitle": "Carica del sistema nervoso autonomo"
- },
- "strain": {
- "title": "Sforzo e carico",
- "subtitle": "Carico cardiovascolare giornaliero"
- },
- "cardio": {
- "title": "Cuore ed energia del giorno",
- "subtitle": "Totali cardiaci ed energetici dell'intera giornata"
- },
"block": {
"ansChargeExplainer": "Quanto è carico il tuo sistema nervoso autonomo per la giornata — una lettura di ricarica ricavata dai segnali notturni di cuore e respiro.",
"dayStrainExplainer": "Il carico cardiovascolare della giornata sulla scala di sforzo del tuo dispositivo. Leggilo in rapporto al tuo recupero, non come obiettivo a sé.",
diff --git a/messages/pl.json b/messages/pl.json
index 6bd2a8336..9b66dda9e 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -3509,18 +3509,6 @@
"description": "Jak dobrze Twój organizm się zregenerował – sygnały regeneracji, obciążenia i load, które Twoje urządzenie rejestruje w nocy.",
"emptyTitle": "Brak danych o regeneracji",
"empty": "Brak danych o regeneracji. Połącz konto WHOOP, Polar lub Oura, a te wyniki uzupełnią się po synchronizacji urządzenia.",
- "recharge": {
- "title": "Recharge",
- "subtitle": "Ładowanie autonomicznego układu nerwowego"
- },
- "strain": {
- "title": "Obciążenie i load",
- "subtitle": "Dzienne obciążenie sercowo-naczyniowe"
- },
- "cardio": {
- "title": "Serce i energia w ciągu dnia",
- "subtitle": "Całodobowe sumy sercowe i energetyczne"
- },
"block": {
"ansChargeExplainer": "Jak naładowany jest Twój autonomiczny układ nerwowy na dany dzień — odczyt regeneracji oparty na nocnych sygnałach serca i oddechu.",
"dayStrainExplainer": "Dzienne obciążenie sercowo-naczyniowe na skali wysiłku Twojego urządzenia. Odczytuj je w odniesieniu do regeneracji, nie jako cel sam w sobie.",
diff --git a/src/components/insights/__tests__/device-score-surfaces.test.tsx b/src/components/insights/__tests__/device-score-surfaces.test.tsx
index 73ae8e968..0018b2cab 100644
--- a/src/components/insights/__tests__/device-score-surfaces.test.tsx
+++ b/src/components/insights/__tests__/device-score-surfaces.test.tsx
@@ -206,7 +206,7 @@ describe(" data-gating", () => {
// Routed through the shared (dashed bordered card), not the
// old hand-rolled div — the empty title comes from the unified primitive.
expect(html).toContain("border-dashed");
- expect(html).not.toContain('data-slot="recovery-group-strain"');
+ expect(html).not.toContain('data-slot="recovery-block-DAY_STRAIN"');
});
it("renders a canonical chart block for the present signal", () => {
@@ -218,13 +218,18 @@ describe(" data-gating", () => {
}),
);
const html = render();
- expect(html).toContain('data-slot="recovery-group-strain"');
+ // v1.18.6 — the per-group sub-headings are gone; present blocks render as
+ // one flat stack. The data gate now reads at the block level.
expect(html).toContain('data-slot="recovery-block-DAY_STRAIN"');
// B3 — the redundant "Recovery score" cross-link block is gone; this
// surface is reached from the overview already.
expect(html).not.toContain('data-slot="recovery-score-link"');
- // The recharge group (ANS charge only) has no data → hidden.
- expect(html).not.toContain('data-slot="recovery-group-recharge"');
+ // The recharge group's only signal (ANS charge) has no data → its block
+ // is dropped.
+ expect(html).not.toContain('data-slot="recovery-block-ANS_CHARGE"');
+ // The removed sub-headings must not reappear.
+ expect(html).not.toContain('data-slot="recovery-group-strain"');
+ expect(html).not.toContain("Strain & load");
// Empty note must not show when at least one signal is present.
expect(html).not.toContain('data-slot="recovery-empty"');
});
diff --git a/src/components/insights/recovery/recovery-metric-block.tsx b/src/components/insights/recovery/recovery-metric-block.tsx
index 09265d492..88527e76e 100644
--- a/src/components/insights/recovery/recovery-metric-block.tsx
+++ b/src/components/insights/recovery/recovery-metric-block.tsx
@@ -77,12 +77,12 @@ export function RecoveryMetricBlock({
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
- {/* v1.18.1 — block title is an `
` (kept at `text-base`): it
- nests under the group `SectionHeading` `
`, which itself
- sits under the page `
` from `SubPageShell`. Demoting it
- keeps the recovery page's heading outline strictly nested
- (h1 → h2 → h3) instead of stacking two sibling `
`s. */}
-
{title}
+ {/* v1.18.6 — block title is an `
` (at `text-base`): the per-group
+ sub-headings were removed, so each block now sits directly under
+ the page `
` from `SubPageShell`. Promoting it from `
` keeps
+ the recovery page's heading outline strictly nested (h1 → h2)
+ with no skipped level. */}
+
);
@@ -210,51 +193,36 @@ export function RecoverySection() {
);
}
+ // v1.18.6 — the per-group sub-headings ("Recharge" / "Strain & load" /
+ // "Daily heart & energy") are gone: read next to the page's "Erholung"
+ // heading they were redundant chrome. The groups still drive ORDER + the
+ // data gate (a group with no present block contributes nothing); their
+ // present blocks now render as one flat, evenly-spaced stack, each block
+ // carrying its own title + explainer so nothing loses context.
+ const presentBlocks = groups.flatMap((group) =>
+ group.blocks.filter((block) => (summaries[block.type]?.count ?? 0) > 0),
+ );
+
return (
{/* v1.18.1 — calm Rest Mode cue at the head of the recovery surface.
Self-gating: renders nothing unless an episode is active. */}
- {groups.map((group) => {
- const present = group.blocks.filter(
- (block) => (summaries[block.type]?.count ?? 0) > 0,
- );
- if (present.length === 0) return null;
- return (
-
-
- {group.subtitle}
-
- }
- />
-
);
}
From 34fbfe1c67ff32a10edcd738a064bd543e963f85 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 21:24:02 +0200
Subject: [PATCH 51/71] feat(insights): link the overview Trends cards to their
detail pages
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The overview charted each trend but never offered a way into the metric's
detail page — the steps page in particular exists and is used, yet Insights
never linked to it. Give every Trends-row chart a routed detail target and a
discreet "view details" link below the caption, so a metric the row surfaces
(steps included, when the briefing flags it) drills into /insights/.
---
messages/de.json | 3 ++-
messages/en.json | 3 ++-
messages/es.json | 3 ++-
messages/fr.json | 3 ++-
messages/it.json | 3 ++-
messages/pl.json | 3 ++-
.../insights/__tests__/trends-row.test.tsx | 18 +++++++++++++++
src/components/insights/trends-row.tsx | 19 +++++++++++++++-
.../__tests__/trend-chart-select.test.ts | 13 +++++++++++
src/lib/insights/trend-chart-select.ts | 22 +++++++++++++++++++
10 files changed, 83 insertions(+), 7 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index 6e9a48271..db398f369 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2029,7 +2029,8 @@
"distance": "Geh- und Laufstrecke der letzten 30 Tage.",
"vo2_max": "VO₂max der letzten 30 Tage.",
"body_temp": "Körpertemperatur der letzten 30 Tage."
- }
+ },
+ "viewDetail": "{metric} im Detail"
},
"vo2Max": {
"statLatest": "Aktuell",
diff --git a/messages/en.json b/messages/en.json
index c33810296..d8373bc5f 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2029,7 +2029,8 @@
"distance": "Walking and running distance over the last 30 days.",
"vo2_max": "VO₂ max over the last 30 days.",
"body_temp": "Body temperature over the last 30 days."
- }
+ },
+ "viewDetail": "View {metric} details"
},
"vo2Max": {
"statLatest": "Latest",
diff --git a/messages/es.json b/messages/es.json
index 0a1b03839..4c76e91d2 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2029,7 +2029,8 @@
"distance": "Distancia caminando y corriendo de los últimos 30 días.",
"vo2_max": "VO₂ máx de los últimos 30 días.",
"body_temp": "Temperatura corporal de los últimos 30 días."
- }
+ },
+ "viewDetail": "Ver detalles de {metric}"
},
"vo2Max": {
"statLatest": "Actual",
diff --git a/messages/fr.json b/messages/fr.json
index 38a840ea8..52691bd65 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2029,7 +2029,8 @@
"distance": "Distance de marche et de course des 30 derniers jours.",
"vo2_max": "VO₂ max des 30 derniers jours.",
"body_temp": "Température corporelle des 30 derniers jours."
- }
+ },
+ "viewDetail": "Voir le détail de {metric}"
},
"vo2Max": {
"statLatest": "Actuel",
diff --git a/messages/it.json b/messages/it.json
index 489c26670..d53d58a54 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2029,7 +2029,8 @@
"distance": "Distanza a piedi e di corsa degli ultimi 30 giorni.",
"vo2_max": "VO₂ max degli ultimi 30 giorni.",
"body_temp": "Temperatura corporea degli ultimi 30 giorni."
- }
+ },
+ "viewDetail": "Vedi i dettagli di {metric}"
},
"vo2Max": {
"statLatest": "Attuale",
diff --git a/messages/pl.json b/messages/pl.json
index 9b66dda9e..a6e7821c5 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2029,7 +2029,8 @@
"distance": "Dystans marszu i biegu z ostatnich 30 dni.",
"vo2_max": "VO₂ max z ostatnich 30 dni.",
"body_temp": "Temperatura ciała z ostatnich 30 dni."
- }
+ },
+ "viewDetail": "Zobacz szczegóły: {metric}"
},
"vo2Max": {
"statLatest": "Aktualne",
diff --git a/src/components/insights/__tests__/trends-row.test.tsx b/src/components/insights/__tests__/trends-row.test.tsx
index 05f33aaa0..34e1fe848 100644
--- a/src/components/insights/__tests__/trends-row.test.tsx
+++ b/src/components/insights/__tests__/trends-row.test.tsx
@@ -284,4 +284,22 @@ describe("", () => {
expect(html).toContain("Moderate confidence");
expect(html).toContain("Low confidence");
});
+
+ // ── v1.18.6 — detail-page cross-links (the Insights→steps gap) ──────
+ it("links each metric card to its detail page", () => {
+ const html = render();
+ // The default BP / weight / mood triple each link to their sub-page.
+ expect(html).toContain('data-slot="trends-row-detail-link"');
+ expect(html).toContain('href="/insights/blood-pressure"');
+ expect(html).toContain('href="/insights/weight"');
+ expect(html).toContain('href="/insights/mood"');
+ });
+
+ it("offers the steps detail link when the briefing flags steps", () => {
+ // Steps has a routed sub-page but the overview never linked to it; a
+ // briefing finding on steps now charts it AND drills into /insights/steps.
+ const html = render();
+ expect(html).toMatch(/data-metric="steps"/);
+ expect(html).toContain('href="/insights/steps"');
+ });
});
diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx
index 5b9bcac4b..5bc1c51b3 100644
--- a/src/components/insights/trends-row.tsx
+++ b/src/components/insights/trends-row.tsx
@@ -1,7 +1,8 @@
"use client";
import dynamic from "next/dynamic";
-import { TrendingUp } from "lucide-react";
+import Link from "next/link";
+import { ArrowRight, TrendingUp } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
@@ -301,6 +302,22 @@ export function TrendsRow({
text={t(config.captionKey)}
/>
)}
+ {/* v1.18.6 — drill-in link to the metric's detail page. The
+ overview charted these metrics but never offered a way INTO
+ their detail pages — notably the steps page, which exists and
+ is used but was unreachable from Insights. The link sits below
+ the caption so the chart keeps its own interactions. */}
+ {config.detailHref ? (
+
+ {t("insights.trendsRow.viewDetail", { metric: title })}
+
+
+ ) : null}
);
})}
diff --git a/src/lib/insights/__tests__/trend-chart-select.test.ts b/src/lib/insights/__tests__/trend-chart-select.test.ts
index 02a3317f4..fa1d12437 100644
--- a/src/lib/insights/__tests__/trend-chart-select.test.ts
+++ b/src/lib/insights/__tests__/trend-chart-select.test.ts
@@ -151,4 +151,17 @@ describe("selectTrendCharts", () => {
}
}
});
+
+ it("every chart-bearing sourceMetric carries a routed detailHref", () => {
+ // v1.18.6 — the overview Trends row drills into the metric's detail page.
+ // Each chartable slot must resolve to a `/insights/` target so the
+ // cross-link never lands on a non-route — steps in particular.
+ for (const [metric, config] of Object.entries(TREND_CHART_CONFIG)) {
+ if (!config) continue;
+ expect(config.detailHref, `${metric} detailHref`).toMatch(
+ /^\/insights\//,
+ );
+ }
+ expect(TREND_CHART_CONFIG.steps?.detailHref).toBe("/insights/steps");
+ });
});
diff --git a/src/lib/insights/trend-chart-select.ts b/src/lib/insights/trend-chart-select.ts
index 8bcb85dd2..73c7b63ff 100644
--- a/src/lib/insights/trend-chart-select.ts
+++ b/src/lib/insights/trend-chart-select.ts
@@ -74,6 +74,15 @@ export interface TrendChartConfig {
* for additive metrics, which fall back to {@link captionKey}.
*/
annotationKey?: TrendAnnotationKey;
+ /**
+ * Routed detail page for the metric (`/insights/`), when one exists.
+ * The Trends-row card links to it so the overview can drill into the metric
+ * — notably the one cross-link to `/insights/steps`, which the overview
+ * otherwise never offered despite the steps page existing and being used.
+ * Omitted for slots with no dedicated page (mood routes through its own
+ * surface; metabolic re-frames live on their composite pages).
+ */
+ detailHref?: string;
}
/**
@@ -100,6 +109,7 @@ export const TREND_CHART_CONFIG: Record<
titleKey: "charts.bloodPressure",
captionKey: "insights.trendsRow.caption.bp",
annotationKey: "bp",
+ detailHref: "/insights/blood-pressure",
},
weight: {
metric: "weight",
@@ -110,6 +120,7 @@ export const TREND_CHART_CONFIG: Record<
titleKey: "charts.weight",
captionKey: "insights.trendsRow.caption.weight",
annotationKey: "weight",
+ detailHref: "/insights/weight",
},
pulse: {
metric: "pulse",
@@ -120,6 +131,7 @@ export const TREND_CHART_CONFIG: Record<
yAxisUnit: "bpm",
titleKey: "charts.pulse",
captionKey: "insights.trendsRow.caption.pulse",
+ detailHref: "/insights/pulse",
},
mood: {
metric: "mood",
@@ -129,6 +141,7 @@ export const TREND_CHART_CONFIG: Record<
titleKey: "charts.mood",
captionKey: "insights.trendsRow.caption.mood",
annotationKey: "mood",
+ detailHref: "/insights/mood",
},
sleep: {
metric: "sleep",
@@ -139,6 +152,7 @@ export const TREND_CHART_CONFIG: Record<
yAxisUnit: "h",
titleKey: "charts.sleep",
captionKey: "insights.trendsRow.caption.sleep",
+ detailHref: "/insights/sleep",
},
steps: {
metric: "steps",
@@ -149,6 +163,7 @@ export const TREND_CHART_CONFIG: Record<
yAxisUnit: "steps",
titleKey: "charts.steps",
captionKey: "insights.trendsRow.caption.steps",
+ detailHref: "/insights/steps",
},
hrv: {
metric: "hrv",
@@ -159,6 +174,7 @@ export const TREND_CHART_CONFIG: Record<
yAxisUnit: "ms",
titleKey: "charts.hrv",
captionKey: "insights.trendsRow.caption.hrv",
+ detailHref: "/insights/hrv",
},
resting_hr: {
metric: "resting_hr",
@@ -169,6 +185,7 @@ export const TREND_CHART_CONFIG: Record<
yAxisUnit: "bpm",
titleKey: "charts.restingHeartRate",
captionKey: "insights.trendsRow.caption.resting_hr",
+ detailHref: "/insights/resting-pulse",
},
active_energy: {
metric: "active_energy",
@@ -179,6 +196,7 @@ export const TREND_CHART_CONFIG: Record<
yAxisUnit: "kcal",
titleKey: "charts.activeEnergy",
captionKey: "insights.trendsRow.caption.active_energy",
+ detailHref: "/insights/active-energy",
},
flights: {
metric: "flights",
@@ -189,6 +207,7 @@ export const TREND_CHART_CONFIG: Record<
yAxisUnit: "flights",
titleKey: "charts.flights",
captionKey: "insights.trendsRow.caption.flights",
+ detailHref: "/insights/flights-climbed",
},
distance: {
metric: "distance",
@@ -199,6 +218,7 @@ export const TREND_CHART_CONFIG: Record<
yAxisUnit: "km",
titleKey: "charts.distance",
captionKey: "insights.trendsRow.caption.distance",
+ detailHref: "/insights/walking-distance",
},
vo2_max: {
metric: "vo2_max",
@@ -209,6 +229,7 @@ export const TREND_CHART_CONFIG: Record<
yAxisUnit: "mL/kg·min",
titleKey: "charts.vo2Max",
captionKey: "insights.trendsRow.caption.vo2_max",
+ detailHref: "/insights/cardio-fitness",
},
body_temp: {
metric: "body_temp",
@@ -219,6 +240,7 @@ export const TREND_CHART_CONFIG: Record<
yAxisUnit: "°C",
titleKey: "charts.bodyTemperature",
captionKey: "insights.trendsRow.caption.body_temp",
+ detailHref: "/insights/body-temperature",
},
// Adherence-context findings — no single plottable trend series in
// this row. A finding on one of these is skipped during selection so
From 33d7d103da764e7033371bdae55abf8355f145a4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 21:28:33 +0200
Subject: [PATCH 52/71] feat(settings): standardize settings and admin section
framing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Introduce a shared SettingsSectionFrame (the settings twin of the admin
renderer's SectionFrame) that emits the canonical visible heading +
subheading, and route all 19 settings sections through it. Every settings
page now shows a real h1 + subtitle in the reference Übersicht style
instead of an invisible sr-only title; the section bodies drop their own
headings and off-style intro paragraphs (the explanatory copy folds into
the frame subtitle).
- Route the admin About page through SectionFrame so it carries the same
visible heading as every other admin section.
- Rename the AI settings section from KI-Auswertung to KI-Anbieter across
six locales: the page configures the provider and key, not an analysis.
- Add a heading-block spacer to the settings and admin sidebars so the
first menu item lines up with the top of the first card.
- Remove the always-on core-domains card from Module: a tracking area that
can't be turned off doesn't need listing.
- Move the proactive-coach control out of Benachrichtigungen into Coach.
- Move admin module availability out of the Coach section into its own
page: it gates every module, not just the coach.
- Give the Layout Insights editor a proper Insights heading.
Adds settings.sections..subtitle and the new admin keys across all
six locales; removes the now-unused module core-domain strings.
---
messages/de.json | 87 +++++-----
messages/en.json | 87 +++++-----
messages/es.json | 87 +++++-----
messages/fr.json | 87 +++++-----
messages/it.json | 87 +++++-----
messages/pl.json | 87 +++++-----
src/app/admin/[section]/renderer.tsx | 34 +++-
src/app/settings/[section]/page.tsx | 60 ++++++-
src/components/admin/admin-shell.tsx | 12 ++
src/components/admin/section-slugs.ts | 12 +-
.../settings/__tests__/coach-section.test.tsx | 10 +-
.../__tests__/export-section.test.tsx | 9 +-
.../gesundheitsakte-section.test.tsx | 11 +-
.../__tests__/medications-section.test.tsx | 7 +-
.../__tests__/modules-section.test.tsx | 41 ++---
.../settings/__tests__/mood-section.test.tsx | 7 +-
...tifications-section-module-gating.test.tsx | 34 ++--
.../__tests__/sections-i18n-parity.test.ts | 32 ++--
.../settings/__tests__/sections.test.tsx | 67 +++++---
.../__tests__/settings-shell.test.tsx | 6 +-
src/components/settings/about-section.tsx | 17 +-
src/components/settings/account-section.tsx | 18 +-
src/components/settings/advanced-section.tsx | 18 +-
src/components/settings/ai-section.tsx | 21 +--
src/components/settings/api-section.tsx | 14 +-
src/components/settings/channels-section.tsx | 25 +--
src/components/settings/coach-section.tsx | 38 +++--
src/components/settings/dashboard-section.tsx | 29 +---
src/components/settings/export-section.tsx | 16 +-
.../settings/gesundheitsakte-section.tsx | 30 +---
src/components/settings/insights-section.tsx | 26 +--
.../settings/integrations-section.tsx | 29 +---
src/components/settings/layout-section.tsx | 73 ++++----
.../settings/medications-section.tsx | 15 +-
src/components/settings/modules-section.tsx | 158 ++++++------------
src/components/settings/mood-section.tsx | 15 +-
.../settings/notifications-section.tsx | 42 ++---
.../settings/section-nav-heading-spacer.tsx | 25 +++
.../settings/settings-section-frame.tsx | 74 ++++++++
src/components/settings/settings-shell.tsx | 8 +-
src/components/settings/sources-section.tsx | 46 +++--
.../settings/thresholds-section.tsx | 23 +--
42 files changed, 822 insertions(+), 802 deletions(-)
create mode 100644 src/components/settings/section-nav-heading-spacer.tsx
create mode 100644 src/components/settings/settings-section-frame.tsx
diff --git a/messages/de.json b/messages/de.json
index 3dcc80763..b9a9a1292 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -3778,18 +3778,21 @@
"sections": {
"account": {
"title": "Konto",
- "description": "Profil, Passwort, Passkeys und deine Onboarding-Tour."
+ "description": "Profil, Passwort, Passkeys und deine Onboarding-Tour.",
+ "subtitle": "Dein Profil, deine Identität und deine Anmeldedaten."
},
"integrations": {
"title": "Integrationen",
- "description": "Verbundene Dienste, Zustellkanäle und die Quellen-Priorität an einem Ort."
+ "description": "Verbundene Dienste, Zustellkanäle und die Quellen-Priorität an einem Ort.",
+ "subtitle": "Datenquellen und Geräte verbinden und verwalten."
},
"notifications": {
"title": "Benachrichtigungen",
"description": "Alle Erinnerungen an einem Ort — was wann erinnert wird. Die Zustellkanäle liegen unter Integrationen.",
"channelsHint": "Telegram, ntfy, Web-Push, Webhook und E-Mail richtest du jetzt unter",
"channelsHintLink": "Integrationen → Kanäle",
- "inboxLink": "Posteingang öffnen"
+ "inboxLink": "Posteingang öffnen",
+ "subtitle": "Wähle, welche Erinnerungen du erhältst."
},
"layout": {
"title": "Layout",
@@ -3802,15 +3805,18 @@
"insights": {
"title": "Insights",
"description": "Reihenfolge der Übersicht und der Navigations-Pills."
- }
+ },
+ "subtitle": "Passe an, wie Dashboard und Insights angeordnet sind."
},
"dashboard": {
"title": "Dashboard",
- "description": "Kachel-Layout und Reihenfolge."
+ "description": "Kachel-Layout und Reihenfolge.",
+ "subtitle": "Ordne die Kacheln auf deinem Dashboard an."
},
"thresholds": {
"title": "Zielwerte",
- "description": "Pro Metrik dein eigener Zielbereich für gesunde Werte."
+ "description": "Pro Metrik dein eigener Zielbereich für gesunde Werte.",
+ "subtitle": "Lege deine persönlichen Zielbereiche je Messgröße fest."
},
"sources": {
"title": "Quellen-Priorität",
@@ -3869,19 +3875,23 @@
"scale": "Waage",
"other": "Sonstige",
"unknown": "Unbekannt"
- }
+ },
+ "subtitle": "Lege fest, welche Quelle gewinnt, wenn zwei dieselbe Messgröße liefern."
},
"ai": {
- "title": "KI-Auswertungen",
- "description": "Provider, Modell und API-Key für die Auswertungen — dazu dein „Über mich“-Kontext."
+ "title": "KI-Anbieter",
+ "description": "Provider, Modell und API-Key für die Auswertungen — dazu dein „Über mich“-Kontext.",
+ "subtitle": "Wähle deinen KI-Anbieter und hinterlege deinen eigenen Schlüssel."
},
"api": {
"title": "API & Tokens",
- "description": "Bearer-Tokens für eigene Skripte und Apps — erfasse Messwerte oder Einnahmen von überall."
+ "description": "Bearer-Tokens für eigene Skripte und Apps — erfasse Messwerte oder Einnahmen von überall.",
+ "subtitle": "API-Endpunkte und Zugriffstoken verwalten."
},
"advanced": {
"title": "Erweitert",
- "description": "Opt-in-Schalter für den Forschungsmodus sowie der Notausgang zum unwiderruflichen Löschen aller Gesundheitsdaten."
+ "description": "Opt-in-Schalter für den Forschungsmodus sowie der Notausgang zum unwiderruflichen Löschen aller Gesundheitsdaten.",
+ "subtitle": "Research-Modus, Daten zurücksetzen und Konto löschen."
},
"export": {
"title": "Export & Import",
@@ -3982,27 +3992,33 @@
"resultSummary": "{inserted} neu importiert und {updated} aktualisiert, {skipped} übersprungen.",
"rowError": "Zeile {line}: {reason}"
}
- }
+ },
+ "subtitle": "Exportiere deine Daten oder importiere aus einer Sicherung."
},
"about": {
"title": "Über diese App",
- "description": "Version, Lizenz, Links."
+ "description": "Version, Lizenz, Links.",
+ "subtitle": "Version, Lizenz und Projektlinks."
},
"sharing": {
"title": "Freigabe",
- "description": "Erstelle einen schreibgeschützten Link, um deine Gesundheitsakte mit einer Ärztin oder einem Arzt zu teilen."
+ "description": "Erstelle einen schreibgeschützten Link, um deine Gesundheitsakte mit einer Ärztin oder einem Arzt zu teilen.",
+ "subtitle": "Teile schreibgeschützten Zugriff auf deine Daten."
},
"insights": {
"title": "Insights",
- "description": "Reihenfolge der Übersicht und der Navigations-Pills."
+ "description": "Reihenfolge der Übersicht und der Navigations-Pills.",
+ "subtitle": "Ordne die Übersichts-Bereiche und die Reihenfolge der Pills an."
},
"medications": {
"title": "Medikamente",
- "description": "Listenansicht, Reihenfolge und Injektionsstellen deiner Medikamente."
+ "description": "Listenansicht, Reihenfolge und Injektionsstellen deiner Medikamente.",
+ "subtitle": "Verwalte Ansicht und Reihenfolge deiner Medikamentenliste."
},
"mood": {
"title": "Stimmungs-Tags",
- "description": "Gruppen, Tags und archivierte Tags der Stimmungs-Auswahl."
+ "description": "Gruppen, Tags und archivierte Tags der Stimmungs-Auswahl.",
+ "subtitle": "Organisiere deine Stimmungs-Tags, Gruppen und Auswahlreihenfolge."
},
"modules": {
"title": "Module",
@@ -4012,26 +4028,24 @@
"title": "Was du trackst",
"description": "Optionale Bereiche. Deaktivierte Module verschwinden aus Navigation, Kacheln und Auswertungen — deine Daten bleiben erhalten."
},
- "core": {
- "title": "Immer aktiv",
- "description": "Die Grundlagen von HealthLog. Diese Bereiche lassen sich nicht abschalten.",
- "note": "Kernbereich — immer aktiv"
- },
- "description": "Lege fest, was HealthLog für dich erfasst. Schalte einzelne Bereiche ein oder aus — die Navigation, Kacheln und Auswertungen passen sich sofort an.",
"manageIn": "In {section} verwalten",
- "operatorDisabled": "Serverweit deaktiviert"
+ "operatorDisabled": "Serverweit deaktiviert",
+ "subtitle": "Lege fest, welche Tracking-Bereiche HealthLog überall anzeigt."
},
"gesundheitsakte": {
"title": "Gesundheitsakte",
- "description": "Vollständige Gesundheitsakte als PDF, FHIR R4 oder kombiniertes ZIP-Paket exportieren."
+ "description": "Vollständige Gesundheitsakte als PDF, FHIR R4 oder kombiniertes ZIP-Paket exportieren.",
+ "subtitle": "Exportiere eine vollständige, arzttaugliche Gesundheitsakte."
},
"coach": {
"title": "Coach",
- "description": "Coach aktivieren, Ton und Umfang, gespeicherte Erinnerungen."
+ "description": "Coach aktivieren, Ton und Umfang, gespeicherte Erinnerungen.",
+ "subtitle": "Stelle den Coach, seine proaktiven Hinweise und sein Gedächtnis ein."
},
"channels": {
"title": "Kanäle",
- "description": "Zustellung über Telegram, ntfy, Web-Push, Webhook und E-Mail."
+ "description": "Zustellung über Telegram, ntfy, Web-Push, Webhook und E-Mail.",
+ "subtitle": "Wohin Erinnerungen und Hinweise zugestellt werden."
}
},
"profile": "Profil",
@@ -5291,7 +5305,12 @@
"subtitle": "Destruktive systemweite Operationen."
},
"about": {
- "title": "Über"
+ "title": "Über",
+ "subtitle": "Version, Lizenz und Update-Status."
+ },
+ "module-availability": {
+ "title": "Modulverfügbarkeit",
+ "subtitle": "Module für jedes Konto auf diesem Server aktivieren oder deaktivieren."
}
},
"carrier": "Anbieter",
@@ -6588,18 +6607,6 @@
"label": "Arztbericht",
"description": "PDF- und FHIR-Export für deine Praxis."
},
- "weight": {
- "label": "Gewicht",
- "description": "Gewicht und Körperzusammensetzung."
- },
- "bloodPressure": {
- "label": "Blutdruck",
- "description": "Systolisch und diastolisch."
- },
- "pulse": {
- "label": "Puls",
- "description": "Ruhepuls und Herzfrequenz."
- },
"medications": {
"label": "Medikamente",
"description": "Pläne, Einnahmen und Compliance."
diff --git a/messages/en.json b/messages/en.json
index a87949920..8b8f063d4 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -3778,18 +3778,21 @@
"sections": {
"account": {
"title": "Account",
- "description": "Profile, password, passkeys, and your onboarding tour."
+ "description": "Profile, password, passkeys, and your onboarding tour.",
+ "subtitle": "Your profile, identity, and sign-in details."
},
"integrations": {
"title": "Integrations",
- "description": "Connected services, delivery channels, and source priority in one place."
+ "description": "Connected services, delivery channels, and source priority in one place.",
+ "subtitle": "Connect and manage your data sources and devices."
},
"notifications": {
"title": "Notifications",
"description": "Every reminder in one place — what gets reminded and when. The delivery channels live under Integrations.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook, and Email are now set up under",
"channelsHintLink": "Integrations → Channels",
- "inboxLink": "Open the inbox"
+ "inboxLink": "Open the inbox",
+ "subtitle": "Choose which reminders you receive."
},
"layout": {
"title": "Layout",
@@ -3802,15 +3805,18 @@
"insights": {
"title": "Insights",
"description": "Overview sections and navigation-pill order."
- }
+ },
+ "subtitle": "Personalize how your dashboard and insights are arranged."
},
"dashboard": {
"title": "Dashboard",
- "description": "Tile layout and order."
+ "description": "Tile layout and order.",
+ "subtitle": "Arrange the tiles on your dashboard."
},
"thresholds": {
"title": "Targets",
- "description": "Per metric: your own target range for healthy values."
+ "description": "Per metric: your own target range for healthy values.",
+ "subtitle": "Set your personal target ranges per metric."
},
"sources": {
"title": "Source priority",
@@ -3869,19 +3875,23 @@
"scale": "Scale",
"other": "Other",
"unknown": "Unknown"
- }
+ },
+ "subtitle": "Decide which source wins when two report the same metric."
},
"ai": {
- "title": "AI Insights",
- "description": "Provider, model, and API key for the insights — plus your “about me” context."
+ "title": "AI provider",
+ "description": "Provider, model, and API key for the insights — plus your “about me” context.",
+ "subtitle": "Choose your AI provider and bring your own key."
},
"api": {
"title": "API & Tokens",
- "description": "Bearer tokens for your own scripts and apps — log measurements or medication intake from anywhere."
+ "description": "Bearer tokens for your own scripts and apps — log measurements or medication intake from anywhere.",
+ "subtitle": "Manage API endpoints and access tokens."
},
"advanced": {
"title": "Advanced",
- "description": "Opt-in Research Mode toggle plus the account-wide danger zone for wiping every health record."
+ "description": "Opt-in Research Mode toggle plus the account-wide danger zone for wiping every health record.",
+ "subtitle": "Research mode, data reset, and account deletion."
},
"export": {
"title": "Export & Import",
@@ -3982,27 +3992,33 @@
"resultSummary": "Imported {inserted} new and updated {updated}, {skipped} skipped.",
"rowError": "Line {line}: {reason}"
}
- }
+ },
+ "subtitle": "Export your data or import from a backup."
},
"about": {
"title": "About this app",
- "description": "Version, license, links."
+ "description": "Version, license, links.",
+ "subtitle": "Version, license, and project links."
},
"sharing": {
"title": "Sharing",
- "description": "Create a read-only link to share your health record with a clinician."
+ "description": "Create a read-only link to share your health record with a clinician.",
+ "subtitle": "Share read-only access to your data."
},
"insights": {
"title": "Insights",
- "description": "Overview and navigation-pill order."
+ "description": "Overview and navigation-pill order.",
+ "subtitle": "Arrange your overview sections and pill order."
},
"medications": {
"title": "Medications",
- "description": "List view, order, and injection sites for your medications."
+ "description": "List view, order, and injection sites for your medications.",
+ "subtitle": "Manage your medication list view and order."
},
"mood": {
"title": "Mood tags",
- "description": "Groups, tags, and archived tags for the mood picker."
+ "description": "Groups, tags, and archived tags for the mood picker.",
+ "subtitle": "Organize your mood tags, groups, and picker order."
},
"modules": {
"title": "Modules",
@@ -4012,26 +4028,24 @@
"title": "What you track",
"description": "Optional areas. Disabled modules drop out of the navigation, tiles, and insights — your data stays untouched."
},
- "core": {
- "title": "Always on",
- "description": "HealthLog's foundations. These areas can't be turned off.",
- "note": "Core area — always on"
- },
- "description": "Choose what HealthLog tracks for you. Turn individual areas on or off — the navigation, tiles, and insights adapt right away.",
"manageIn": "Manage in {section}",
- "operatorDisabled": "Disabled server-wide"
+ "operatorDisabled": "Disabled server-wide",
+ "subtitle": "Choose which tracking areas HealthLog shows across the app."
},
"gesundheitsakte": {
"title": "Health record",
- "description": "Export your full health record as PDF, FHIR R4, or a combined ZIP package."
+ "description": "Export your full health record as PDF, FHIR R4, or a combined ZIP package.",
+ "subtitle": "Export a complete, clinician-ready health record."
},
"coach": {
"title": "Coach",
- "description": "Enable the Coach, tone and scope, stored memories."
+ "description": "Enable the Coach, tone and scope, stored memories.",
+ "subtitle": "Tune the Coach, its proactive nudges, and its memory."
},
"channels": {
"title": "Channels",
- "description": "Telegram, ntfy, Web Push, Webhook, and Email delivery."
+ "description": "Telegram, ntfy, Web Push, Webhook, and Email delivery.",
+ "subtitle": "Where reminders and alerts are delivered."
}
},
"profile": "Profile",
@@ -5291,7 +5305,12 @@
"subtitle": "Destructive system-wide operations."
},
"about": {
- "title": "About"
+ "title": "About",
+ "subtitle": "Version, license, and update status."
+ },
+ "module-availability": {
+ "title": "Module availability",
+ "subtitle": "Turn modules on or off for every account on this server."
}
},
"carrier": "Carrier",
@@ -6588,18 +6607,6 @@
"label": "Doctor report",
"description": "PDF and FHIR export for your practice."
},
- "weight": {
- "label": "Weight",
- "description": "Weight and body composition."
- },
- "bloodPressure": {
- "label": "Blood pressure",
- "description": "Systolic and diastolic."
- },
- "pulse": {
- "label": "Pulse",
- "description": "Resting pulse and heart rate."
- },
"medications": {
"label": "Medications",
"description": "Schedules, intakes, and compliance."
diff --git a/messages/es.json b/messages/es.json
index 99b6d7782..ad87143c8 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -3778,18 +3778,21 @@
"sections": {
"account": {
"title": "Cuenta",
- "description": "Perfil, contraseña, claves de acceso y tu recorrido de incorporación."
+ "description": "Perfil, contraseña, claves de acceso y tu recorrido de incorporación.",
+ "subtitle": "Tu perfil, identidad y datos de acceso."
},
"integrations": {
"title": "Integraciones",
- "description": "Servicios conectados, canales de entrega y prioridad de fuentes en un solo lugar."
+ "description": "Servicios conectados, canales de entrega y prioridad de fuentes en un solo lugar.",
+ "subtitle": "Conecta y gestiona tus fuentes de datos y dispositivos."
},
"notifications": {
"title": "Notificaciones",
"description": "Todos los recordatorios en un solo lugar: qué se recuerda y cuándo. Los canales de entrega están en Integraciones.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook y correo se configuran ahora en",
"channelsHintLink": "Integraciones → Canales",
- "inboxLink": "Abrir la bandeja de entrada"
+ "inboxLink": "Abrir la bandeja de entrada",
+ "subtitle": "Elige qué recordatorios recibes."
},
"layout": {
"title": "Diseño",
@@ -3802,15 +3805,18 @@
"insights": {
"title": "Análisis",
"description": "Orden de la vista general y de las pestañas de navegación."
- }
+ },
+ "subtitle": "Personaliza cómo se organizan tu panel e insights."
},
"dashboard": {
"title": "Panel",
- "description": "Diseño y orden de los mosaicos."
+ "description": "Diseño y orden de los mosaicos.",
+ "subtitle": "Organiza los mosaicos de tu panel."
},
"thresholds": {
"title": "Objetivos",
- "description": "Por métrica: tu propio rango objetivo de valores saludables."
+ "description": "Por métrica: tu propio rango objetivo de valores saludables.",
+ "subtitle": "Define tus rangos objetivo personales por métrica."
},
"sources": {
"title": "Prioridad de fuentes",
@@ -3869,19 +3875,23 @@
"scale": "Báscula",
"other": "Otro",
"unknown": "Desconocido"
- }
+ },
+ "subtitle": "Decide qué fuente prevalece cuando dos informan la misma métrica."
},
"ai": {
- "title": "Análisis con IA",
- "description": "Proveedor, modelo y clave API para los análisis, además de tu contexto «sobre mí»."
+ "title": "Proveedor de IA",
+ "description": "Proveedor, modelo y clave API para los análisis, además de tu contexto «sobre mí».",
+ "subtitle": "Elige tu proveedor de IA y usa tu propia clave."
},
"api": {
"title": "API & Tokens",
- "description": "Tokens Bearer para tus propios scripts y apps — registra mediciones o tomas de medicación desde cualquier lugar."
+ "description": "Tokens Bearer para tus propios scripts y apps — registra mediciones o tomas de medicación desde cualquier lugar.",
+ "subtitle": "Gestiona los endpoints de la API y los tokens de acceso."
},
"advanced": {
"title": "Avanzado",
- "description": "Activador opt-in del modo Investigación y zona de peligro para borrar irrevocablemente todos los registros de salud."
+ "description": "Activador opt-in del modo Investigación y zona de peligro para borrar irrevocablemente todos los registros de salud.",
+ "subtitle": "Modo investigación, restablecer datos y eliminar cuenta."
},
"export": {
"title": "Exportar e importar",
@@ -3982,27 +3992,33 @@
"resultSummary": "{inserted} nuevas importadas y {updated} actualizadas, {skipped} omitidas.",
"rowError": "Línea {line}: {reason}"
}
- }
+ },
+ "subtitle": "Exporta tus datos o importa desde una copia de seguridad."
},
"about": {
"title": "Acerca de esta app",
- "description": "Versión, licencia, enlaces."
+ "description": "Versión, licencia, enlaces.",
+ "subtitle": "Versión, licencia y enlaces del proyecto."
},
"sharing": {
"title": "Compartir",
- "description": "Crea un enlace de solo lectura para compartir tu historial de salud con un profesional médico."
+ "description": "Crea un enlace de solo lectura para compartir tu historial de salud con un profesional médico.",
+ "subtitle": "Comparte acceso de solo lectura a tus datos."
},
"insights": {
"title": "Análisis",
- "description": "Orden de la vista general y de las pestañas de navegación."
+ "description": "Orden de la vista general y de las pestañas de navegación.",
+ "subtitle": "Organiza las secciones de tu resumen y el orden de las píldoras."
},
"medications": {
"title": "Medicamentos",
- "description": "Vista de lista, orden y zonas de inyección de tus medicamentos."
+ "description": "Vista de lista, orden y zonas de inyección de tus medicamentos.",
+ "subtitle": "Gestiona la vista y el orden de tu lista de medicamentos."
},
"mood": {
"title": "Etiquetas de ánimo",
- "description": "Grupos, etiquetas y etiquetas archivadas del selector de ánimo."
+ "description": "Grupos, etiquetas y etiquetas archivadas del selector de ánimo.",
+ "subtitle": "Organiza tus etiquetas de ánimo, grupos y orden del selector."
},
"modules": {
"title": "Módulos",
@@ -4012,26 +4028,24 @@
"title": "Qué registras",
"description": "Áreas opcionales. Los módulos desactivados desaparecen de la navegación, los paneles y los análisis; tus datos se conservan."
},
- "core": {
- "title": "Siempre activos",
- "description": "Los pilares de HealthLog. Estas áreas no se pueden desactivar.",
- "note": "Área esencial — siempre activa"
- },
- "description": "Elige qué registra HealthLog por ti. Activa o desactiva áreas concretas: la navegación, los paneles y los análisis se adaptan al instante.",
"manageIn": "Gestionar en {section}",
- "operatorDisabled": "Desactivado en todo el servidor"
+ "operatorDisabled": "Desactivado en todo el servidor",
+ "subtitle": "Elige qué áreas de seguimiento muestra HealthLog en la app."
},
"gesundheitsakte": {
"title": "Historial médico",
- "description": "Exporta tu historial médico completo como PDF, FHIR R4 o un paquete ZIP combinado."
+ "description": "Exporta tu historial médico completo como PDF, FHIR R4 o un paquete ZIP combinado.",
+ "subtitle": "Exporta un historial de salud completo, listo para el médico."
},
"coach": {
"title": "Coach",
- "description": "Activa el Coach, tono y alcance, recuerdos guardados."
+ "description": "Activa el Coach, tono y alcance, recuerdos guardados.",
+ "subtitle": "Ajusta el Coach, sus avisos proactivos y su memoria."
},
"channels": {
"title": "Canales",
- "description": "Envío por Telegram, ntfy, Web Push, Webhook y correo electrónico."
+ "description": "Envío por Telegram, ntfy, Web Push, Webhook y correo electrónico.",
+ "subtitle": "Dónde se entregan los recordatorios y avisos."
}
},
"profile": "Perfil",
@@ -5291,7 +5305,12 @@
"subtitle": "Operaciones destructivas en todo el sistema."
},
"about": {
- "title": "Acerca de"
+ "title": "Acerca de",
+ "subtitle": "Versión, licencia y estado de actualizaciones."
+ },
+ "module-availability": {
+ "title": "Disponibilidad de módulos",
+ "subtitle": "Activa o desactiva módulos para todas las cuentas de este servidor."
}
},
"carrier": "Operador",
@@ -6588,18 +6607,6 @@
"label": "Informe médico",
"description": "Exportación PDF y FHIR para tu consulta."
},
- "weight": {
- "label": "Peso",
- "description": "Peso y composición corporal."
- },
- "bloodPressure": {
- "label": "Presión arterial",
- "description": "Sistólica y diastólica."
- },
- "pulse": {
- "label": "Pulso",
- "description": "Pulso en reposo y frecuencia cardiaca."
- },
"medications": {
"label": "Medicamentos",
"description": "Pautas, tomas y cumplimiento."
diff --git a/messages/fr.json b/messages/fr.json
index 85cb525e9..1c74cf1bf 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -3778,18 +3778,21 @@
"sections": {
"account": {
"title": "Compte",
- "description": "Profil, mot de passe, clés d’accès et votre visite guidée."
+ "description": "Profil, mot de passe, clés d’accès et votre visite guidée.",
+ "subtitle": "Votre profil, votre identité et vos identifiants."
},
"integrations": {
"title": "Intégrations",
- "description": "Services connectés, canaux de diffusion et priorité des sources au même endroit."
+ "description": "Services connectés, canaux de diffusion et priorité des sources au même endroit.",
+ "subtitle": "Connectez et gérez vos sources de données et appareils."
},
"notifications": {
"title": "Notifications",
"description": "Tous les rappels au même endroit — quoi et quand. Les canaux de diffusion se trouvent dans Intégrations.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook et e-mail se configurent désormais dans",
"channelsHintLink": "Intégrations → Canaux",
- "inboxLink": "Ouvrir la boîte de réception"
+ "inboxLink": "Ouvrir la boîte de réception",
+ "subtitle": "Choisissez les rappels que vous recevez."
},
"layout": {
"title": "Disposition",
@@ -3802,15 +3805,18 @@
"insights": {
"title": "Analyses",
"description": "Ordre de la vue d'ensemble et des onglets de navigation."
- }
+ },
+ "subtitle": "Personnalisez l'agencement de votre tableau de bord et de vos insights."
},
"dashboard": {
"title": "Tableau de bord",
- "description": "Disposition et ordre des tuiles."
+ "description": "Disposition et ordre des tuiles.",
+ "subtitle": "Organisez les tuiles de votre tableau de bord."
},
"thresholds": {
"title": "Objectifs",
- "description": "Par métrique : votre propre plage cible pour des valeurs saines."
+ "description": "Par métrique : votre propre plage cible pour des valeurs saines.",
+ "subtitle": "Définissez vos plages cibles personnelles par mesure."
},
"sources": {
"title": "Priorité des sources",
@@ -3869,19 +3875,23 @@
"scale": "Balance",
"other": "Autre",
"unknown": "Inconnu"
- }
+ },
+ "subtitle": "Choisissez la source prioritaire en cas de doublon de mesure."
},
"ai": {
- "title": "Analyses IA",
- "description": "Fournisseur, modèle et clé API pour les analyses, ainsi que votre contexte « à propos de moi »."
+ "title": "Fournisseur d'IA",
+ "description": "Fournisseur, modèle et clé API pour les analyses, ainsi que votre contexte « à propos de moi ».",
+ "subtitle": "Choisissez votre fournisseur d'IA et utilisez votre propre clé."
},
"api": {
"title": "API & Tokens",
- "description": "Jetons Bearer pour vos propres scripts et applis — enregistrez des mesures ou des prises de médicaments depuis n’importe où."
+ "description": "Jetons Bearer pour vos propres scripts et applis — enregistrez des mesures ou des prises de médicaments depuis n’importe où.",
+ "subtitle": "Gérez les points d'accès API et les jetons d'accès."
},
"advanced": {
"title": "Avancé",
- "description": "Bouton opt-in du mode Recherche et zone dangereuse pour effacer irrévocablement toutes les données de santé."
+ "description": "Bouton opt-in du mode Recherche et zone dangereuse pour effacer irrévocablement toutes les données de santé.",
+ "subtitle": "Mode recherche, réinitialisation des données et suppression du compte."
},
"export": {
"title": "Export et import",
@@ -3982,27 +3992,33 @@
"resultSummary": "{inserted} nouvelles importées et {updated} mises à jour, {skipped} ignorées.",
"rowError": "Ligne {line} : {reason}"
}
- }
+ },
+ "subtitle": "Exportez vos données ou importez depuis une sauvegarde."
},
"about": {
"title": "À propos de cette app",
- "description": "Version, licence, liens."
+ "description": "Version, licence, liens.",
+ "subtitle": "Version, licence et liens du projet."
},
"sharing": {
"title": "Partage",
- "description": "Créez un lien en lecture seule pour partager votre dossier de santé avec un professionnel de santé."
+ "description": "Créez un lien en lecture seule pour partager votre dossier de santé avec un professionnel de santé.",
+ "subtitle": "Partagez un accès en lecture seule à vos données."
},
"insights": {
"title": "Analyses",
- "description": "Ordre de la vue d'ensemble et des onglets de navigation."
+ "description": "Ordre de la vue d'ensemble et des onglets de navigation.",
+ "subtitle": "Organisez les sections d'aperçu et l'ordre des pastilles."
},
"medications": {
"title": "Médicaments",
- "description": "Vue liste, ordre et sites d’injection de vos médicaments."
+ "description": "Vue liste, ordre et sites d’injection de vos médicaments.",
+ "subtitle": "Gérez l'affichage et l'ordre de votre liste de médicaments."
},
"mood": {
"title": "Étiquettes d’humeur",
- "description": "Groupes, étiquettes et étiquettes archivées du sélecteur d’humeur."
+ "description": "Groupes, étiquettes et étiquettes archivées du sélecteur d’humeur.",
+ "subtitle": "Organisez vos tags d'humeur, groupes et ordre du sélecteur."
},
"modules": {
"title": "Modules",
@@ -4012,26 +4028,24 @@
"title": "Ce que vous suivez",
"description": "Domaines optionnels. Les modules désactivés disparaissent de la navigation, des tuiles et des analyses ; vos données sont conservées."
},
- "core": {
- "title": "Toujours actifs",
- "description": "Les fondations de HealthLog. Ces domaines ne peuvent pas être désactivés.",
- "note": "Domaine essentiel — toujours actif"
- },
- "description": "Choisissez ce que HealthLog suit pour vous. Activez ou désactivez chaque domaine : la navigation, les tuiles et les analyses s'adaptent aussitôt.",
"manageIn": "Gérer dans {section}",
- "operatorDisabled": "Désactivé sur tout le serveur"
+ "operatorDisabled": "Désactivé sur tout le serveur",
+ "subtitle": "Choisissez les domaines de suivi affichés par HealthLog."
},
"gesundheitsakte": {
"title": "Dossier médical",
- "description": "Exportez votre dossier médical complet au format PDF, FHIR R4 ou en paquet ZIP combiné."
+ "description": "Exportez votre dossier médical complet au format PDF, FHIR R4 ou en paquet ZIP combiné.",
+ "subtitle": "Exportez un dossier de santé complet, prêt pour le médecin."
},
"coach": {
"title": "Coach",
- "description": "Activez le Coach, le ton et la portée, les souvenirs enregistrés."
+ "description": "Activez le Coach, le ton et la portée, les souvenirs enregistrés.",
+ "subtitle": "Réglez le Coach, ses suggestions proactives et sa mémoire."
},
"channels": {
"title": "Canaux",
- "description": "Distribution via Telegram, ntfy, Web Push, Webhook et e-mail."
+ "description": "Distribution via Telegram, ntfy, Web Push, Webhook et e-mail.",
+ "subtitle": "Où sont envoyés les rappels et alertes."
}
},
"profile": "Profil",
@@ -5291,7 +5305,12 @@
"subtitle": "Opérations destructrices à l'échelle du système."
},
"about": {
- "title": "À propos"
+ "title": "À propos",
+ "subtitle": "Version, licence et état des mises à jour."
+ },
+ "module-availability": {
+ "title": "Disponibilité des modules",
+ "subtitle": "Activez ou désactivez les modules pour tous les comptes de ce serveur."
}
},
"carrier": "Opérateur",
@@ -6588,18 +6607,6 @@
"label": "Rapport médical",
"description": "Export PDF et FHIR pour votre cabinet."
},
- "weight": {
- "label": "Poids",
- "description": "Poids et composition corporelle."
- },
- "bloodPressure": {
- "label": "Tension artérielle",
- "description": "Systolique et diastolique."
- },
- "pulse": {
- "label": "Pouls",
- "description": "Pouls au repos et fréquence cardiaque."
- },
"medications": {
"label": "Médicaments",
"description": "Schémas, prises et observance."
diff --git a/messages/it.json b/messages/it.json
index 48507ed1d..8f3123811 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -3778,18 +3778,21 @@
"sections": {
"account": {
"title": "Account",
- "description": "Profilo, password, passkey e il tour di benvenuto."
+ "description": "Profilo, password, passkey e il tour di benvenuto.",
+ "subtitle": "Il tuo profilo, la tua identità e i dati di accesso."
},
"integrations": {
"title": "Integrazioni",
- "description": "Servizi collegati, canali di consegna e priorità delle fonti in un unico posto."
+ "description": "Servizi collegati, canali di consegna e priorità delle fonti in un unico posto.",
+ "subtitle": "Collega e gestisci le tue fonti di dati e i dispositivi."
},
"notifications": {
"title": "Notifiche",
"description": "Tutti i promemoria in un unico posto — cosa e quando. I canali di recapito si trovano in Integrazioni.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook ed e-mail ora si configurano in",
"channelsHintLink": "Integrazioni → Canali",
- "inboxLink": "Apri la posta in arrivo"
+ "inboxLink": "Apri la posta in arrivo",
+ "subtitle": "Scegli quali promemoria ricevere."
},
"layout": {
"title": "Layout",
@@ -3802,15 +3805,18 @@
"insights": {
"title": "Approfondimenti",
"description": "Ordine della panoramica e delle schede di navigazione."
- }
+ },
+ "subtitle": "Personalizza la disposizione di dashboard e insights."
},
"dashboard": {
"title": "Cruscotto",
- "description": "Layout e ordine dei riquadri."
+ "description": "Layout e ordine dei riquadri.",
+ "subtitle": "Disponi i riquadri della tua dashboard."
},
"thresholds": {
"title": "Obiettivi",
- "description": "Per metrica: il tuo intervallo target per valori sani."
+ "description": "Per metrica: il tuo intervallo target per valori sani.",
+ "subtitle": "Imposta i tuoi intervalli target personali per metrica."
},
"sources": {
"title": "Priorità delle fonti",
@@ -3869,19 +3875,23 @@
"scale": "Bilancia",
"other": "Altro",
"unknown": "Sconosciuto"
- }
+ },
+ "subtitle": "Decidi quale fonte prevale quando due riportano la stessa metrica."
},
"ai": {
- "title": "Analisi IA",
- "description": "Provider, modello e chiave API per gli approfondimenti, oltre al tuo contesto «su di me»."
+ "title": "Provider IA",
+ "description": "Provider, modello e chiave API per gli approfondimenti, oltre al tuo contesto «su di me».",
+ "subtitle": "Scegli il tuo provider IA e usa la tua chiave."
},
"api": {
"title": "API & Tokens",
- "description": "Token Bearer per i tuoi script e le tue app — registra misurazioni o assunzioni di farmaci da ovunque."
+ "description": "Token Bearer per i tuoi script e le tue app — registra misurazioni o assunzioni di farmaci da ovunque.",
+ "subtitle": "Gestisci gli endpoint API e i token di accesso."
},
"advanced": {
"title": "Avanzate",
- "description": "Interruttore opt-in della modalità Ricerca e zona pericolosa per cancellare in modo irrevocabile tutti i dati di salute."
+ "description": "Interruttore opt-in della modalità Ricerca e zona pericolosa per cancellare in modo irrevocabile tutti i dati di salute.",
+ "subtitle": "Modalità ricerca, ripristino dati ed eliminazione account."
},
"export": {
"title": "Esporta e importa",
@@ -3982,27 +3992,33 @@
"resultSummary": "{inserted} nuove importate e {updated} aggiornate, {skipped} saltate.",
"rowError": "Riga {line}: {reason}"
}
- }
+ },
+ "subtitle": "Esporta i tuoi dati o importa da un backup."
},
"about": {
"title": "Informazioni su questa app",
- "description": "Versione, licenza, link."
+ "description": "Versione, licenza, link.",
+ "subtitle": "Versione, licenza e link al progetto."
},
"sharing": {
"title": "Condivisione",
- "description": "Crea un link di sola lettura per condividere la tua cartella clinica con un medico."
+ "description": "Crea un link di sola lettura per condividere la tua cartella clinica con un medico.",
+ "subtitle": "Condividi l'accesso in sola lettura ai tuoi dati."
},
"insights": {
"title": "Approfondimenti",
- "description": "Ordine della panoramica e delle schede di navigazione."
+ "description": "Ordine della panoramica e delle schede di navigazione.",
+ "subtitle": "Disponi le sezioni della panoramica e l'ordine dei pill."
},
"medications": {
"title": "Farmaci",
- "description": "Vista elenco, ordine e siti di iniezione dei tuoi farmaci."
+ "description": "Vista elenco, ordine e siti di iniezione dei tuoi farmaci.",
+ "subtitle": "Gestisci vista e ordine del tuo elenco di farmaci."
},
"mood": {
"title": "Tag dell’umore",
- "description": "Gruppi, tag e tag archiviati del selettore dell’umore."
+ "description": "Gruppi, tag e tag archiviati del selettore dell’umore.",
+ "subtitle": "Organizza i tag dell'umore, i gruppi e l'ordine del selettore."
},
"modules": {
"title": "Moduli",
@@ -4012,26 +4028,24 @@
"title": "Cosa monitori",
"description": "Ambiti opzionali. I moduli disattivati spariscono da navigazione, riquadri e analisi; i tuoi dati restano intatti."
},
- "core": {
- "title": "Sempre attivi",
- "description": "Le fondamenta di HealthLog. Questi ambiti non possono essere disattivati.",
- "note": "Ambito di base — sempre attivo"
- },
- "description": "Scegli cosa registra HealthLog per te. Attiva o disattiva i singoli ambiti: navigazione, riquadri e analisi si adattano subito.",
"manageIn": "Gestisci in {section}",
- "operatorDisabled": "Disattivato a livello di server"
+ "operatorDisabled": "Disattivato a livello di server",
+ "subtitle": "Scegli quali aree di monitoraggio HealthLog mostra nell'app."
},
"gesundheitsakte": {
"title": "Cartella clinica",
- "description": "Esporta la tua cartella clinica completa in PDF, FHIR R4 o in un pacchetto ZIP combinato."
+ "description": "Esporta la tua cartella clinica completa in PDF, FHIR R4 o in un pacchetto ZIP combinato.",
+ "subtitle": "Esporta una cartella clinica completa, pronta per il medico."
},
"coach": {
"title": "Coach",
- "description": "Attiva il Coach, tono e ambito, ricordi salvati."
+ "description": "Attiva il Coach, tono e ambito, ricordi salvati.",
+ "subtitle": "Regola il Coach, i suoi suggerimenti proattivi e la sua memoria."
},
"channels": {
"title": "Canali",
- "description": "Recapito tramite Telegram, ntfy, Web Push, Webhook ed e-mail."
+ "description": "Recapito tramite Telegram, ntfy, Web Push, Webhook ed e-mail.",
+ "subtitle": "Dove vengono recapitati promemoria e avvisi."
}
},
"profile": "Profilo",
@@ -5291,7 +5305,12 @@
"subtitle": "Operazioni distruttive a livello di sistema."
},
"about": {
- "title": "Informazioni"
+ "title": "Informazioni",
+ "subtitle": "Versione, licenza e stato degli aggiornamenti."
+ },
+ "module-availability": {
+ "title": "Disponibilità dei moduli",
+ "subtitle": "Attiva o disattiva i moduli per tutti gli account su questo server."
}
},
"carrier": "Operatore",
@@ -6588,18 +6607,6 @@
"label": "Referto medico",
"description": "Esportazione PDF e FHIR per il tuo studio."
},
- "weight": {
- "label": "Peso",
- "description": "Peso e composizione corporea."
- },
- "bloodPressure": {
- "label": "Pressione",
- "description": "Sistolica e diastolica."
- },
- "pulse": {
- "label": "Battito",
- "description": "Battito a riposo e frequenza cardiaca."
- },
"medications": {
"label": "Farmaci",
"description": "Schemi, assunzioni e aderenza."
diff --git a/messages/pl.json b/messages/pl.json
index c60c651f4..5da1f13f1 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -3778,18 +3778,21 @@
"sections": {
"account": {
"title": "Konto",
- "description": "Profil, hasło, klucze dostępu i przewodnik wprowadzający."
+ "description": "Profil, hasło, klucze dostępu i przewodnik wprowadzający.",
+ "subtitle": "Twój profil, tożsamość i dane logowania."
},
"integrations": {
"title": "Integracje",
- "description": "Połączone usługi, kanały dostarczania i priorytet źródeł w jednym miejscu."
+ "description": "Połączone usługi, kanały dostarczania i priorytet źródeł w jednym miejscu.",
+ "subtitle": "Połącz i zarządzaj źródłami danych oraz urządzeniami."
},
"notifications": {
"title": "Powiadomienia",
"description": "Wszystkie przypomnienia w jednym miejscu — co i kiedy. Kanały dostarczania znajdują się w Integracjach.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook i e-mail konfigurujesz teraz w",
"channelsHintLink": "Integracje → Kanały",
- "inboxLink": "Otwórz skrzynkę odbiorczą"
+ "inboxLink": "Otwórz skrzynkę odbiorczą",
+ "subtitle": "Wybierz, które przypomnienia otrzymujesz."
},
"layout": {
"title": "Układ",
@@ -3802,15 +3805,18 @@
"insights": {
"title": "Analizy",
"description": "Kolejność przeglądu i pigułek nawigacyjnych."
- }
+ },
+ "subtitle": "Dostosuj układ pulpitu i analiz."
},
"dashboard": {
"title": "Pulpit",
- "description": "Układ i kolejność kafelków."
+ "description": "Układ i kolejność kafelków.",
+ "subtitle": "Rozmieść kafelki na pulpicie."
},
"thresholds": {
"title": "Cele",
- "description": "Dla każdej metryki: własny zakres docelowy zdrowych wartości."
+ "description": "Dla każdej metryki: własny zakres docelowy zdrowych wartości.",
+ "subtitle": "Ustaw osobiste zakresy docelowe dla każdej metryki."
},
"sources": {
"title": "Priorytet źródeł",
@@ -3869,19 +3875,23 @@
"scale": "Waga",
"other": "Inne",
"unknown": "Nieznane"
- }
+ },
+ "subtitle": "Zdecyduj, które źródło wygrywa, gdy dwa podają tę samą metrykę."
},
"ai": {
- "title": "Analizy AI",
- "description": "Dostawca, model i klucz API do analiz, a także Twój kontekst „o mnie”."
+ "title": "Dostawca AI",
+ "description": "Dostawca, model i klucz API do analiz, a także Twój kontekst „o mnie”.",
+ "subtitle": "Wybierz dostawcę AI i użyj własnego klucza."
},
"api": {
"title": "API & Tokens",
- "description": "Tokeny Bearer dla własnych skryptów i aplikacji — zapisuj pomiary lub przyjęcia leków z dowolnego miejsca."
+ "description": "Tokeny Bearer dla własnych skryptów i aplikacji — zapisuj pomiary lub przyjęcia leków z dowolnego miejsca.",
+ "subtitle": "Zarządzaj punktami końcowymi API i tokenami dostępu."
},
"advanced": {
"title": "Zaawansowane",
- "description": "Opt-in przełącznika trybu Badawczego oraz strefa zagrożenia umożliwiająca nieodwracalne wymazanie wszystkich danych zdrowotnych."
+ "description": "Opt-in przełącznika trybu Badawczego oraz strefa zagrożenia umożliwiająca nieodwracalne wymazanie wszystkich danych zdrowotnych.",
+ "subtitle": "Tryb badawczy, reset danych i usunięcie konta."
},
"export": {
"title": "Eksport i import",
@@ -3982,27 +3992,33 @@
"resultSummary": "Zaimportowano {inserted} nowych i zaktualizowano {updated}, pominięto {skipped}.",
"rowError": "Wiersz {line}: {reason}"
}
- }
+ },
+ "subtitle": "Wyeksportuj dane lub zaimportuj z kopii zapasowej."
},
"about": {
"title": "O tej aplikacji",
- "description": "Wersja, licencja, linki."
+ "description": "Wersja, licencja, linki.",
+ "subtitle": "Wersja, licencja i linki do projektu."
},
"sharing": {
"title": "Udostępnianie",
- "description": "Utwórz link tylko do odczytu, aby udostępnić swoją dokumentację zdrowotną lekarzowi."
+ "description": "Utwórz link tylko do odczytu, aby udostępnić swoją dokumentację zdrowotną lekarzowi.",
+ "subtitle": "Udostępnij dostęp tylko do odczytu do swoich danych."
},
"insights": {
"title": "Analizy",
- "description": "Kolejność przeglądu i pigułek nawigacyjnych."
+ "description": "Kolejność przeglądu i pigułek nawigacyjnych.",
+ "subtitle": "Rozmieść sekcje przeglądu i kolejność etykiet."
},
"medications": {
"title": "Leki",
- "description": "Widok listy, kolejność i miejsca wstrzyknięć Twoich leków."
+ "description": "Widok listy, kolejność i miejsca wstrzyknięć Twoich leków.",
+ "subtitle": "Zarządzaj widokiem i kolejnością listy leków."
},
"mood": {
"title": "Tagi nastroju",
- "description": "Grupy, tagi i zarchiwizowane tagi w wyborze nastroju."
+ "description": "Grupy, tagi i zarchiwizowane tagi w wyborze nastroju.",
+ "subtitle": "Uporządkuj tagi nastroju, grupy i kolejność wyboru."
},
"modules": {
"title": "Moduły",
@@ -4012,26 +4028,24 @@
"title": "Co śledzisz",
"description": "Obszary opcjonalne. Wyłączone moduły znikają z nawigacji, kafelków i analiz — Twoje dane pozostają nienaruszone."
},
- "core": {
- "title": "Zawsze aktywne",
- "description": "Podstawy HealthLog. Tych obszarów nie można wyłączyć.",
- "note": "Obszar podstawowy — zawsze aktywny"
- },
- "description": "Wybierz, co HealthLog ma dla Ciebie śledzić. Włączaj i wyłączaj poszczególne obszary — nawigacja, kafelki i analizy dostosują się od razu.",
"manageIn": "Zarządzaj w {section}",
- "operatorDisabled": "Wyłączone na całym serwerze"
+ "operatorDisabled": "Wyłączone na całym serwerze",
+ "subtitle": "Wybierz, które obszary śledzenia pokazuje HealthLog w aplikacji."
},
"gesundheitsakte": {
"title": "Dokumentacja zdrowotna",
- "description": "Wyeksportuj pełną dokumentację zdrowotną jako PDF, FHIR R4 lub łączony pakiet ZIP."
+ "description": "Wyeksportuj pełną dokumentację zdrowotną jako PDF, FHIR R4 lub łączony pakiet ZIP.",
+ "subtitle": "Eksportuj kompletną dokumentację zdrowotną gotową dla lekarza."
},
"coach": {
"title": "Coach",
- "description": "Włącz Coacha, ton i zakres, zapisane wspomnienia."
+ "description": "Włącz Coacha, ton i zakres, zapisane wspomnienia.",
+ "subtitle": "Dostosuj Coacha, jego proaktywne podpowiedzi i pamięć."
},
"channels": {
"title": "Kanały",
- "description": "Dostarczanie przez Telegram, ntfy, Web Push, Webhook i e-mail."
+ "description": "Dostarczanie przez Telegram, ntfy, Web Push, Webhook i e-mail.",
+ "subtitle": "Gdzie dostarczane są przypomnienia i powiadomienia."
}
},
"profile": "Profil",
@@ -5291,7 +5305,12 @@
"subtitle": "Destrukcyjne operacje obejmujące cały system."
},
"about": {
- "title": "O programie"
+ "title": "O programie",
+ "subtitle": "Wersja, licencja i stan aktualizacji."
+ },
+ "module-availability": {
+ "title": "Dostępność modułów",
+ "subtitle": "Włącz lub wyłącz moduły dla wszystkich kont na tym serwerze."
}
},
"carrier": "Operator",
@@ -6588,18 +6607,6 @@
"label": "Raport dla lekarza",
"description": "Eksport PDF i FHIR dla Twojej przychodni."
},
- "weight": {
- "label": "Waga",
- "description": "Waga i skład ciała."
- },
- "bloodPressure": {
- "label": "Ciśnienie krwi",
- "description": "Skurczowe i rozkurczowe."
- },
- "pulse": {
- "label": "Tętno",
- "description": "Tętno spoczynkowe i rytm serca."
- },
"medications": {
"label": "Leki",
"description": "Schematy, przyjęcia i zgodność."
diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx
index aeea25435..4adc26c77 100644
--- a/src/app/admin/[section]/renderer.tsx
+++ b/src/app/admin/[section]/renderer.tsx
@@ -94,18 +94,32 @@ export function AdminSectionRenderer({
// v1.18.1 — the former ai-quality, assistant, and coach-feedback
// sections fold into one Coach area: surface toggles + operator
// config first, then the two feedback-quality tables.
+ // v1.18.6 (W9) — server-wide module availability moved out to its own
+ // `module-availability` section: it gates EVERY module, not just the
+ // coach, so it no longer belongs stacked under Coach.
return (
-
);
+ case "module-availability":
+ // v1.18.6 (W9) — operator-side server-wide module on/off, its own
+ // admin section (was stacked under Coach). Reconciled name: the
+ // user-facing "Module" settings pick from what is available here.
+ return (
+
+
+
+ );
case "feedback":
return (
);
case "about":
- // v1.4.36 W4e — About section reused as-is from the settings
- // surface. The component owns its own heading + cards layout
- // so no SectionFrame wrapper.
- // v1.18.6 — the About surface no longer carries the tour-replay card
- // (consolidated onto Settings → Advanced), so there's nothing to hide.
- return ;
+ // v1.4.36 W4e — About section reused as-is from the settings surface.
+ // v1.18.6 (W9) — route it through `SectionFrame` like every other admin
+ // section so it gets the same visible heading + subtitle (the one admin
+ // page that was bypassing the frame and showing no heading).
+ return (
+
+
+
+ );
default:
slug satisfies never;
return null;
diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx
index 78115b334..b5295c215 100644
--- a/src/app/settings/[section]/page.tsx
+++ b/src/app/settings/[section]/page.tsx
@@ -1,5 +1,5 @@
import { notFound } from "next/navigation";
-import type { JSX } from "react";
+import type { JSX, ReactNode } from "react";
import { AccountSection } from "@/components/settings/account-section";
import { AboutSection } from "@/components/settings/about-section";
@@ -29,6 +29,9 @@ import {
type SettingsSectionSlug,
} from "@/components/settings/section-slugs";
import { SettingsShell } from "@/components/settings/settings-shell";
+import { SettingsSectionFrame } from "@/components/settings/settings-section-frame";
+import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
+import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
/**
* Dynamic settings section route. Each of the `SETTINGS_SECTION_SLUGS`
@@ -71,6 +74,50 @@ const SECTION_COMPONENTS: Record<
advanced: AdvancedSection,
};
+// v1.18.6 (W9) — the two Layout-hub child editors render a "← back to hub"
+// link above their heading. The shared `` exposes a
+// `topSlot` for exactly this; the link lives here so the section bodies stay
+// pure card content.
+const HUB_CHILD_SLUGS: ReadonlySet = new Set([
+ "dashboard",
+ "insights",
+]);
+
+/** Optional content rendered above the canonical heading, per slug. */
+function frameTopSlot(slug: SettingsSectionSlug): ReactNode {
+ if (HUB_CHILD_SLUGS.has(slug)) {
+ return (
+
+ );
+ }
+ return undefined;
+}
+
+/** Optional inline content rendered at the top-right of the heading row. */
+function frameHeadingAccessory(slug: SettingsSectionSlug): ReactNode {
+ // v1.18.6 (W9) — the module guided-tour re-entry triggers used to live in
+ // a `justify-end` header inside the section body; they move to the frame's
+ // heading row so the heading itself is the spotlight anchor.
+ if (slug === "gesundheitsakte") {
+ return (
+
+
+
+ );
+ }
+ if (slug === "integrations") {
+ return (
+
+
+
+ );
+ }
+ return undefined;
+}
+
interface PageProps {
// Next.js 16 made route `params` an async Promise. We `await` it before use.
params: Promise<{ section: string }>;
@@ -91,8 +138,17 @@ export default async function SettingsSectionPage({ params }: PageProps) {
// guards the slug, but a future slug added to `SETTINGS_SECTION_SLUGS`
// without a wired component would otherwise crash silently — placeholder
// surfaces the gap visually instead.
+ // v1.18.6 (W9) — every section now renders inside the shared
+ // ``, which emits the one visible heading + subtitle
+ // each page must show (the section bodies no longer carry their own).
const body = SectionComponent ? (
-
+
+
+
) : (
);
diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx
index 70924d726..4b44785cf 100644
--- a/src/components/admin/admin-shell.tsx
+++ b/src/components/admin/admin-shell.tsx
@@ -18,6 +18,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Bell,
+ Blocks,
Database,
FileText,
Info,
@@ -39,6 +40,7 @@ import { cn } from "@/lib/utils";
import { scrollBehaviorForUser } from "@/lib/motion";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
+import { SectionNavHeadingSpacer } from "@/components/settings/section-nav-heading-spacer";
import { isAdminSectionSlug, type AdminSectionSlug } from "./section-slugs";
interface AdminSection {
@@ -82,6 +84,13 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
titleKey: "admin.section.coach.title",
icon: Sparkles,
},
+ // v1.18.6 (W9) — server-wide module availability split out of Coach into
+ // its own entry, sitting next to it.
+ {
+ slug: "module-availability",
+ titleKey: "admin.section.module-availability.title",
+ icon: Blocks,
+ },
{
slug: "feedback",
titleKey: "admin.section.feedback.title",
@@ -276,6 +285,9 @@ export function AdminShell({ active, children }: AdminShellProps) {
className="hidden md:block"
>
+ {/* v1.18.6 (W9) — align the first nav item with the top of the
+ first card, matching the settings shell. */}
+
({
}));
import { I18nProvider } from "@/lib/i18n/context";
+import { SettingsSectionFrame } from "../settings-section-frame";
import { CoachSection } from "../coach-section";
function render(locale: "en" | "de" = "en") {
+ // v1.18.6 (W9) — the visible heading lives in the shared frame the route
+ // wraps the section in; render through it so the heading id is present.
return renderToStaticMarkup(
-
+
+
+ ,
);
}
@@ -51,6 +56,9 @@ describe(" — SSR smoke", () => {
// v1.18.1 (D7) — polarity flipped to activate/default-on.
expect(html).toContain("Activate Coach");
expect(html).toContain("Show the Coach button and drawer. On by default");
+ // v1.18.6 (W9) — the proactive Coach nudge card moved here from
+ // Benachrichtigungen; its anchor renders under the enabled-coach gate.
+ expect(html).toContain('id="coach-nudge"');
// Raw key never leaks past i18n.
expect(html).not.toContain("settings.sections.coach.");
expect(html).not.toContain("settings.coach.activate.");
diff --git a/src/components/settings/__tests__/export-section.test.tsx b/src/components/settings/__tests__/export-section.test.tsx
index 264277863..bc648c7d2 100644
--- a/src/components/settings/__tests__/export-section.test.tsx
+++ b/src/components/settings/__tests__/export-section.test.tsx
@@ -44,6 +44,7 @@ vi.mock("@/hooks/use-auth", () => ({
}));
import { I18nProvider } from "@/lib/i18n/context";
+import { SettingsSectionFrame } from "../settings-section-frame";
import { ExportSection } from "../export-section";
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
@@ -54,7 +55,13 @@ function render(node: React.ReactElement, locale: "en" | "de" = "en") {
describe(" — SSR smoke", () => {
it("renders the section heading + description", () => {
- const html = render();
+ // v1.18.6 (W9) — the page heading comes from the shared frame the route
+ // wraps the section in.
+ const html = render(
+
+
+ ,
+ );
// v1.15.7 — the section was relabelled "Export & Import" (issue #281).
expect(html).toContain("Export & Import");
// Raw key never leaks past i18n.
diff --git a/src/components/settings/__tests__/gesundheitsakte-section.test.tsx b/src/components/settings/__tests__/gesundheitsakte-section.test.tsx
index 57ab31493..baee6ffba 100644
--- a/src/components/settings/__tests__/gesundheitsakte-section.test.tsx
+++ b/src/components/settings/__tests__/gesundheitsakte-section.test.tsx
@@ -29,6 +29,7 @@ vi.mock("@/hooks/use-auth", () => ({
}));
import { I18nProvider } from "@/lib/i18n/context";
+import { SettingsSectionFrame } from "../settings-section-frame";
import { GesundheitsakteSection } from "../gesundheitsakte-section";
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
@@ -38,8 +39,14 @@ function render(node: React.ReactElement, locale: "en" | "de" = "en") {
}
describe(" — SSR smoke", () => {
- it("renders the section description and the health-record panel", () => {
- const html = render();
+ it("renders the section heading and the health-record panel", () => {
+ // v1.18.6 (W9) — the visible heading (with the historic id) comes from
+ // the shared frame the route wraps the section in.
+ const html = render(
+
+
+ ,
+ );
expect(html).toContain('id="settings-section-gesundheitsakte-title"');
expect(html).toContain('data-testid="health-record-export-panel"');
// Raw key never leaks past i18n.
diff --git a/src/components/settings/__tests__/medications-section.test.tsx b/src/components/settings/__tests__/medications-section.test.tsx
index 7fc9b3d70..7bc97a732 100644
--- a/src/components/settings/__tests__/medications-section.test.tsx
+++ b/src/components/settings/__tests__/medications-section.test.tsx
@@ -65,12 +65,17 @@ vi.mock("@/hooks/use-auth", () => ({
}));
import { I18nProvider } from "@/lib/i18n/context";
+import { SettingsSectionFrame } from "../settings-section-frame";
import { MedicationsSection } from "../medications-section";
function render(locale: "en" | "de" = "en"): string {
+ // v1.18.6 (W9) — the visible heading (with the historic id) comes from the
+ // shared frame the route wraps the section in.
return renderToStaticMarkup(
-
+
+
+ ,
);
}
diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx
index 32d23c180..cb97fa27b 100644
--- a/src/components/settings/__tests__/modules-section.test.tsx
+++ b/src/components/settings/__tests__/modules-section.test.tsx
@@ -20,11 +20,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { AuthUser } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
-import {
- MODULE_KEYS,
- CORE_DOMAIN_KEYS,
- moduleDelegatesTo,
-} from "@/lib/modules/registry";
+import { MODULE_KEYS, moduleDelegatesTo } from "@/lib/modules/registry";
// Capture the latest useMutation config + a shared mutate spy so we can
// invoke `mutationFn` / `onSuccess` by hand (the SSR pass can't fire a
@@ -46,9 +42,9 @@ vi.mock("@tanstack/react-query", () => ({
},
}));
-const authSpy = vi.fn<() => { user: AuthUser | null; isAuthenticated: boolean }>(
- () => ({ user: buildUser({}), isAuthenticated: true }),
-);
+const authSpy = vi.fn<
+ () => { user: AuthUser | null; isAuthenticated: boolean }
+>(() => ({ user: buildUser({}), isAuthenticated: true }));
vi.mock("@/hooks/use-auth", async () => {
const actual =
await vi.importActual(
@@ -109,7 +105,10 @@ afterEach(() => {
});
describe("", () => {
- it("renders a Switch for every non-delegated toggleable module, a managed deep-link for delegated ones, and locked switches for core", () => {
+ it("renders a Switch for every non-delegated toggleable module and a managed deep-link for delegated ones", () => {
+ // v1.18.6 (W9) — the "Always on" core-domains card was removed (a domain
+ // that can't be turned off doesn't need listing), so this section now
+ // only renders the toggleable modules.
const html = render();
for (const key of MODULE_KEYS) {
if (moduleDelegatesTo(key) !== undefined) {
@@ -126,8 +125,16 @@ describe("", () => {
);
}
}
- for (const key of CORE_DOMAIN_KEYS) {
- expect(html, `core switch ${key}`).toContain(`id="module-toggle-${key}"`);
+ });
+
+ it("no longer renders the 'Always on' core-domains card", () => {
+ // v1.18.6 (W9) — the read-only core card (weight / BP / pulse) was
+ // removed; the core domains are always active regardless.
+ const html = render();
+ for (const key of ["weight", "bloodPressure", "pulse"]) {
+ expect(html, `core ${key} not rendered`).not.toContain(
+ `id="module-toggle-${key}"`,
+ );
}
});
@@ -152,18 +159,6 @@ describe("", () => {
expect(moodTag).toMatch(/data-state="checked"/);
});
- it("renders the core domains locked (checked + disabled)", () => {
- const html = render();
- for (const key of CORE_DOMAIN_KEYS) {
- const tag = html.match(
- new RegExp(`]*id="module-toggle-${key}"[^>]*>`),
- )?.[0];
- expect(tag, `core ${key}`).toBeDefined();
- expect(tag).toMatch(/data-state="checked"/);
- expect(tag).toMatch(/disabled/);
- }
- });
-
it("PATCHes only the flipped key as a disabled-allowlist entry", async () => {
render();
expect(lastMutation?.mutationFn).toBeTypeOf("function");
diff --git a/src/components/settings/__tests__/mood-section.test.tsx b/src/components/settings/__tests__/mood-section.test.tsx
index 8f3975704..5f949344b 100644
--- a/src/components/settings/__tests__/mood-section.test.tsx
+++ b/src/components/settings/__tests__/mood-section.test.tsx
@@ -131,12 +131,17 @@ vi.mock("@/hooks/use-auth", () => ({
}));
import { I18nProvider } from "@/lib/i18n/context";
+import { SettingsSectionFrame } from "../settings-section-frame";
import { MoodSection } from "../mood-section";
function render(locale: "en" | "de" = "en"): string {
+ // v1.18.6 (W9) — the visible heading (with the historic id) comes from the
+ // shared frame the route wraps the section in.
return renderToStaticMarkup(
-
+
+
+ ,
);
}
diff --git a/src/components/settings/__tests__/notifications-section-module-gating.test.tsx b/src/components/settings/__tests__/notifications-section-module-gating.test.tsx
index 4cc5cd46b..7b7831108 100644
--- a/src/components/settings/__tests__/notifications-section-module-gating.test.tsx
+++ b/src/components/settings/__tests__/notifications-section-module-gating.test.tsx
@@ -2,10 +2,13 @@
* v1.18.0 (S4) — module-gated per-type visibility on the consolidated
* "Benachrichtigungen" reminder-types home.
*
- * The mood reminder shows only when the `mood` module is enabled; the Coach
- * nudge only when `coach` is enabled. Low-stock maps to the medications CORE
- * domain (always shown). The gate fails OPEN, so a `/me` payload without a
- * module map keeps every card visible.
+ * The mood reminder shows only when the `mood` module is enabled. Low-stock
+ * maps to the medications domain (shown unless explicitly disabled). The gate
+ * fails OPEN, so a `/me` payload without a module map keeps every card
+ * visible.
+ *
+ * v1.18.6 (W9) — the proactive Coach nudge card moved to Settings → Coach, so
+ * it no longer appears on this screen.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -47,30 +50,23 @@ function render(modules?: Record) {
}
describe(" module-gated reminder types", () => {
- it("shows mood + coach cards when no module map (fails open)", () => {
+ it("shows mood + low-stock cards when no module map (fails open); no coach nudge", () => {
const html = render(undefined);
expect(html).toContain('id="mood-reminder"');
- expect(html).toContain('id="coach-nudge"');
expect(html).toContain('id="low-stock"');
+ // v1.18.6 (W9) — the coach nudge moved to Settings → Coach.
+ expect(html).not.toContain('id="coach-nudge"');
});
it("hides the mood card when the mood module is disabled", () => {
const html = render({ mood: false });
expect(html).not.toContain('id="mood-reminder"');
- // Coach + low-stock are unaffected.
- expect(html).toContain('id="coach-nudge"');
+ // Low-stock is unaffected.
expect(html).toContain('id="low-stock"');
});
- it("hides the coach card when the coach module is disabled", () => {
- const html = render({ coach: false });
- expect(html).not.toContain('id="coach-nudge"');
- expect(html).toContain('id="mood-reminder"');
- expect(html).toContain('id="low-stock"');
- });
-
- it("always shows low-stock (medications is a CORE domain)", () => {
- const html = render({ mood: false, coach: false });
+ it("always shows low-stock unless medications is disabled", () => {
+ const html = render({ mood: false });
expect(html).toContain('id="low-stock"');
expect(html).not.toContain('id="mood-reminder"');
expect(html).not.toContain('id="coach-nudge"');
@@ -82,9 +78,9 @@ describe(" module-gated reminder types", () => {
const html = render({});
expect(html).not.toContain('data-slot="notifications-channels-cross-link"');
expect(html).not.toContain('data-slot="notifications-inbox-cross-link"');
- // The three reminder-type cards remain.
+ // The remaining reminder-type cards (no coach nudge here anymore).
expect(html).toContain('id="mood-reminder"');
expect(html).toContain('id="low-stock"');
- expect(html).toContain('id="coach-nudge"');
+ expect(html).not.toContain('id="coach-nudge"');
});
});
diff --git a/src/components/settings/__tests__/sections-i18n-parity.test.ts b/src/components/settings/__tests__/sections-i18n-parity.test.ts
index f85254a4d..d51d75cfc 100644
--- a/src/components/settings/__tests__/sections-i18n-parity.test.ts
+++ b/src/components/settings/__tests__/sections-i18n-parity.test.ts
@@ -2,11 +2,11 @@
* v1.4.16 phase B6 — settings i18n parity.
*
* Every slug declared in `SETTINGS_SECTION_SLUGS` MUST have a
- * `settings.sections..title` AND a `settings.sections..description`
- * resolved in both English and German. The audit doc
- * (`docs/audit/v1416-settings-audit.md`) calls this out as the canonical
- * shape — the sidebar, the page `
`, and the page subtitle all read
- * from these two keys, so a missing key paints a raw key in production.
+ * `settings.sections..title` AND a `settings.sections..subtitle`
+ * resolved in both English and German. The sidebar reads `.title`; the shared
+ * `` reads `.title` + `.subtitle` for the visible page
+ * heading + subheading (v1.18.6 W9), so a missing key paints a raw key in
+ * production.
*/
import { describe, expect, it } from "vitest";
@@ -46,43 +46,39 @@ describe("settings sections — i18n parity (B6)", () => {
expect(deValue?.length ?? 0).toBeGreaterThan(0);
});
- it(`every locale resolves settings.sections.${slug}.description`, () => {
+ it(`every locale resolves settings.sections.${slug}.subtitle`, () => {
const enValue = lookup(en as Bag, [
"settings",
"sections",
slug,
- "description",
+ "subtitle",
]);
const deValue = lookup(de as Bag, [
"settings",
"sections",
slug,
- "description",
+ "subtitle",
]);
- expect(enValue, `EN missing description for ${slug}`).toBeTypeOf(
- "string",
- );
+ expect(enValue, `EN missing subtitle for ${slug}`).toBeTypeOf("string");
expect(enValue?.length ?? 0).toBeGreaterThan(0);
- expect(deValue, `DE missing description for ${slug}`).toBeTypeOf(
- "string",
- );
+ expect(deValue, `DE missing subtitle for ${slug}`).toBeTypeOf("string");
expect(deValue?.length ?? 0).toBeGreaterThan(0);
});
}
- it("EN and DE description for every slug are different (catches accidental copy-paste)", () => {
+ it("EN and DE subtitle for every slug are different (catches accidental copy-paste)", () => {
for (const slug of SETTINGS_SECTION_SLUGS) {
const enValue = lookup(en as Bag, [
"settings",
"sections",
slug,
- "description",
+ "subtitle",
]);
const deValue = lookup(de as Bag, [
"settings",
"sections",
slug,
- "description",
+ "subtitle",
]);
// It's legitimate for some titles to coincide between locales when
// the proper noun is the same (e.g. "Dashboard", "API & Tokens").
@@ -90,7 +86,7 @@ describe("settings sections — i18n parity (B6)", () => {
// EN value into the DE bag is a bug.
expect(
deValue,
- `DE description for ${slug} is identical to EN — needs translation`,
+ `DE subtitle for ${slug} is identical to EN — needs translation`,
).not.toBe(enValue);
}
});
diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx
index 50704d6e9..34ac19d7e 100644
--- a/src/components/settings/__tests__/sections.test.tsx
+++ b/src/components/settings/__tests__/sections.test.tsx
@@ -87,6 +87,8 @@ vi.mock("@/components/settings/thresholds-editor-section", () => ({
ThresholdsEditorSection: () => ,
}));
import { I18nProvider } from "@/lib/i18n/context";
+import { SettingsSectionFrame } from "../settings-section-frame";
+import type { SettingsSectionSlug } from "../section-slugs";
import { AboutSection } from "../about-section";
import { AccountSection } from "../account-section";
import { AdvancedSection } from "../advanced-section";
@@ -108,10 +110,25 @@ function render(node: React.ReactElement, locale: "en" | "de" = "en") {
);
}
+// v1.18.6 (W9) — the visible page heading + subtitle moved out of the section
+// bodies into the shared `` that the route wraps every
+// section in. The heading-level smoke checks render the body THROUGH the frame
+// (mirroring production) so they assert what the user actually sees.
+function renderFramed(
+ slug: SettingsSectionSlug,
+ node: React.ReactElement,
+ locale: "en" | "de" = "en",
+) {
+ return render(
+ {node},
+ locale,
+ );
+}
+
describe("settings sections — SSR smoke", () => {
it(" renders the Account heading and Profile card", () => {
- const html = render();
- // Section title resolves via the i18n provider.
+ const html = renderFramed("account", );
+ // Section title resolves via the frame + i18n provider.
expect(html).toContain("Account");
// Profile card heading is also painted.
expect(html).toContain("Profile");
@@ -136,7 +153,7 @@ describe("settings sections — SSR smoke", () => {
});
it(" renders version + license + sources/docs cards (no Updates panel)", () => {
- const html = render();
+ const html = renderFramed("about", );
expect(html).toContain("About");
// v1.4.36 W4f — the dedicated "Updates" card with its manual
// "Check now" button is gone. The 24 h auto-check still runs and
@@ -158,34 +175,33 @@ describe("settings sections — SSR smoke", () => {
expect(html).not.toContain("Replay the tour");
});
- it(" renders the AI Insights heading", () => {
- const html = render();
- // v1.8.7.1 — section renamed to "AI Insights" so the AI nature of the
- // surface is explicit (was the bare "Insights"). The section H1 carries
- // the only heading.
- expect(html).toContain("AI Insights");
+ it(" renders the AI provider heading", () => {
+ const html = renderFramed("ai", );
+ // v1.18.6 (W9) — section renamed to "AI provider"; the visible heading
+ // now comes from the shared frame, which keeps the historic id.
+ expect(html).toContain("AI provider");
expect(html).toContain("settings-section-ai-title");
});
it(" renders Withings card title", () => {
- const html = render();
+ const html = renderFramed("integrations", );
expect(html).toContain("Integrations");
expect(html).toContain("Withings");
});
it(" renders the single reminder-types home", () => {
- const html = render();
- // v1.18.1 (D0/D5) — the screen is lean: sr-only h1 only, no section blurb,
- // no channels/inbox cross-links, and no embedded Vorsorge editor.
+ const html = renderFramed("notifications", );
+ // v1.18.6 (W9) — the visible heading comes from the frame; the proactive
+ // Coach nudge moved to Settings → Coach, so it no longer renders here.
expect(html).toContain("settings-section-notifications-title");
expect(html).not.toContain("Notification channels");
expect(html).not.toContain('data-slot="notifications-channels-cross-link"');
expect(html).not.toContain('data-slot="notifications-inbox-cross-link"');
- // The mood + coach cards fail OPEN (mock user carries no module map),
- // so all three reminder-type cards render.
+ // The mood card fails OPEN (mock user carries no module map), so both
+ // remaining reminder-type cards render. The coach-nudge card is gone.
expect(html).toContain('id="mood-reminder"');
- expect(html).toContain('id="coach-nudge"');
expect(html).toContain('id="low-stock"');
+ expect(html).not.toContain('id="coach-nudge"');
// No raw i18n key leaks past the provider.
expect(html).not.toContain("settings.sections.notifications.");
});
@@ -194,32 +210,31 @@ describe("settings sections — SSR smoke", () => {
// v1.4.3 split: the per-metric thresholds (Persönliche Zielwerte) moved
// out into their own settings section under `/settings/thresholds`,
// so the dashboard panel is now layout-only. Verifying the heading
- // says "Dashboard" (not "Übersicht" — the v1.4.2 placeholder) and the
- // layout slot still mounts.
- const html = render();
+ // (supplied by the frame) says "Dashboard" and the layout slot mounts.
+ const html = renderFramed("dashboard", );
expect(html).toContain("Dashboard");
expect(html).toContain('data-testid="dashboard-layout"');
expect(html).not.toContain('data-testid="thresholds"');
});
it(" renders endpoints + tokens cards", () => {
- const html = render();
+ const html = renderFramed("api", );
expect(html).toContain("API & Tokens");
});
it(" renders the danger-zone card only (export moved to /settings/export)", () => {
- const html = render();
+ const html = renderFramed("advanced", );
// v1.4.16 phase B7: every export path moved out into the dedicated
// `` so what remains is the irreversible delete-all
- // path. The section heading + dangerZone surface are the only
- // primary surfaces here now.
+ // path. The heading (supplied by the frame) + dangerZone surface are the
+ // only primary surfaces here now.
expect(html).toContain("Advanced");
expect(html).not.toContain("settings.export");
expect(html).not.toContain("doctorReport");
});
it(" resolves about.* keys in German", () => {
- const html = render(, "de");
+ const html = renderFramed("about", , "de");
// v1.4.36 W4f — "Updates" panel + "Jetzt prüfen" button removed;
// the surviving German copy is the section title + the Sources
// & docs heading + the version line.
@@ -235,7 +250,7 @@ describe("settings sections — SSR smoke", () => {
// `` (was ``).
// v1.8.7.1 — Sources split back onto its own page, so this wrapper
// renders the target-range editor only.
- const html = render();
+ const html = renderFramed("thresholds", );
// v1.8.7.1: section renamed to "Targets".
expect(html).toContain("Targets");
expect(html).toContain('data-testid="thresholds-editor"');
@@ -244,7 +259,7 @@ describe("settings sections — SSR smoke", () => {
});
it(" resolves the German title", () => {
- const html = render(, "de");
+ const html = renderFramed("thresholds", , "de");
// v1.8.7.1 — German title is "Zielwerte" (Targets only).
expect(html).toContain("Zielwerte");
expect(html).toContain('data-testid="thresholds-editor"');
diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx
index ce27d54d2..275a4ec9f 100644
--- a/src/components/settings/__tests__/settings-shell.test.tsx
+++ b/src/components/settings/__tests__/settings-shell.test.tsx
@@ -319,9 +319,9 @@ describe("", () => {
expect(html).toContain('href="/settings/mood"');
// Targets keeps its German nav entry ("Zielwerte").
expect(html).toContain("Zielwerte");
- // v1.8.7.1 — the AI Insights section is named "KI-Auswertungen" in
- // German (the "KI" prefix makes the AI nature explicit).
- expect(html).toContain("KI-Auswertungen");
+ // v1.18.6 (W9) — the AI section is named "KI-Anbieter" in German: the
+ // page is about the provider / BYOK, not an "Auswertung".
+ expect(html).toContain("KI-Anbieter");
// API & Tokens is identical in both locales (proper noun + ampersand)
expect(html).toContain("API & Tokens");
expect(html).toContain("Erweitert");
diff --git a/src/components/settings/about-section.tsx b/src/components/settings/about-section.tsx
index 847c43357..787c5b46a 100644
--- a/src/components/settings/about-section.tsx
+++ b/src/components/settings/about-section.tsx
@@ -181,17 +181,12 @@ export function AboutSection() {
}, [version?.version]);
/* eslint-enable react-hooks/exhaustive-deps, react-hooks/set-state-in-effect */
+ // v1.18.6 (W9) — the visible heading + subtitle now come from the section
+ // frame that wraps this component: `` on the settings
+ // route and the admin renderer's `SectionFrame` on `/admin/about`. The body
+ // is the version / license / sources cards.
return (
-
-
-
- {t("settings.sections.about.title")}
-
-
-
+
{/* Identity card — version + license inline. The v1.4.2 layout
stacked the license under the version inside its own boxed Badge,
which read as a separate field even though the two values belong
@@ -321,6 +316,6 @@ export function AboutSection() {
button were collectively never the point of the surface —
the only signal users ever cared about was "is a newer
release out?", and the badge answers that with no clicks. */}
-
+
);
}
diff --git a/src/components/settings/account-section.tsx b/src/components/settings/account-section.tsx
index bcd976592..bddb12312 100644
--- a/src/components/settings/account-section.tsx
+++ b/src/components/settings/account-section.tsx
@@ -367,20 +367,10 @@ export function AccountSection() {
if (!user) return null;
+ // v1.18.6 (W9) — the visible heading + subtitle now come from the shared
+ // `` in the route; this body is the profile cards.
return (
-
- {/* v1.18.1 (D0) — the explanatory section blurb was dropped so every
- settings section starts at the same vertical height; the sr-only
- heading stays for the accessibility tree. */}
-
-
);
}
diff --git a/src/components/settings/advanced-section.tsx b/src/components/settings/advanced-section.tsx
index 98462ca15..b8710d71f 100644
--- a/src/components/settings/advanced-section.tsx
+++ b/src/components/settings/advanced-section.tsx
@@ -51,26 +51,16 @@ import { apiFetchRaw } from "@/lib/api/api-fetch";
* dialog.
*/
export function AdvancedSection() {
- const { t } = useTranslations();
-
+ // v1.18.6 (W9) — the visible heading + subtitle now come from the shared
+ // `` in the route; this body is the advanced cards.
return (
-
- {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
-
-
- {t("settings.sections.advanced.title")}
-
-
-
+
{/* v1.18.1 (D1) — "Tour neu starten" relocated here from Account. */}
-
+
);
}
diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx
index 2ee988267..8cd89e9a7 100644
--- a/src/components/settings/ai-section.tsx
+++ b/src/components/settings/ai-section.tsx
@@ -21,10 +21,8 @@
import { AiInsightsCard } from "@/components/settings/ai/ai-insights-card";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
-import { useTranslations } from "@/lib/i18n/context";
export function AiSection() {
- const { t } = useTranslations();
const { isAuthenticated } = useAuth();
// v1.16.6 — the auth query can resolve before this boundary
// hydrates; every `disabled={!isAuthenticated}` binding in the child
@@ -35,19 +33,8 @@ export function AiSection() {
const mounted = useMounted();
const authed = mounted && isAuthenticated;
- return (
-
- {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
-
-
- {t("settings.sections.ai.title")}
-
-
-
-
-
- {/* v1.18.1 (D8) — the "About me" context moved to Settings → Account
- (under Profil, before Zyklus-Tracking). */}
-
- );
+ // v1.18.6 (W9) — the visible heading + subtitle now come from the shared
+ // `` in the route; this body is the single provider
+ // card. (The "About me" context moved to Settings → Account in v1.18.1 D8.)
+ return ;
}
diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx
index 8580b14cc..417b081de 100644
--- a/src/components/settings/api-section.tsx
+++ b/src/components/settings/api-section.tsx
@@ -37,19 +37,13 @@ interface ApiTokenInfo {
}
export function ApiSection() {
- const { t } = useTranslations();
-
+ // v1.18.6 (W9) — the visible heading + subtitle now come from the shared
+ // `` in the route; this body is the API cards.
return (
-
-
-
- {t("settings.sections.api.title")}
-
-
-
+
-
+
);
}
diff --git a/src/components/settings/channels-section.tsx b/src/components/settings/channels-section.tsx
index 65da12c95..ebceea87e 100644
--- a/src/components/settings/channels-section.tsx
+++ b/src/components/settings/channels-section.tsx
@@ -8,30 +8,13 @@
* delivery provider — the same family as a connected device — and now reads as
* a first-class settings home rather than a tab hidden behind Integrationen.
*
- * The content is the existing ``; this wrapper only
- * owns the section frame (sr-only heading scaffold matching every other
- * settings section).
+ * The content is the existing ``. v1.18.6 (W9) —
+ * the visible heading + subtitle now come from the shared
+ * `` in the route; this wrapper is the panel only.
*/
import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel";
-import { useTranslations } from "@/lib/i18n/context";
export function ChannelsSection() {
- const { t } = useTranslations();
-
- return (
-
- {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
-
-
- {t("settings.sections.channels.title")}
-
-
-
-
-
- );
+ return ;
}
diff --git a/src/components/settings/coach-section.tsx b/src/components/settings/coach-section.tsx
index 2d732bad2..7bdc78e9e 100644
--- a/src/components/settings/coach-section.tsx
+++ b/src/components/settings/coach-section.tsx
@@ -10,25 +10,31 @@
* 1. Disable Coach — the per-user opt-out (kept first so a user who
* wants the surface gone finds it immediately).
* 2. Coach preferences — tone, verbosity, data scope (`useCoachPrefs`).
- * 3. Coach memory — durable-fact review + forget controls.
+ * 3. Proactive nudge — the daily-nudge opt-out + per-group frequency
+ * (moved here from Settings → Benachrichtigungen in
+ * v1.18.6: it is a Coach setting, not a generic
+ * notification).
+ * 4. Coach memory — durable-fact review + forget controls.
*
- * The nav entry is module-gated on `coach`. The preference + memory cards
- * keep their own content gate on `!user.disableCoach` (mirrored from the
+ * The nav entry is module-gated on `coach`. The preference + nudge + memory
+ * cards keep their own content gate on `!user.disableCoach` (mirrored from the
* old AI section): hiding the Coach hides its tuning + memory controls too.
*
* The "about me" context lives in the AI section, not here — the daily
* briefing reads it too, so it is not a Coach-only setting.
+ *
+ * v1.18.6 (W9) — the visible heading + subtitle now come from the shared
+ * `` in the route; this body is the Coach cards only.
*/
import { DisableCoachCard } from "@/components/settings/ai/disable-coach-card";
import { CoachMemorySection } from "@/components/settings/coach-memory-section";
+import { CoachNudgeCard } from "@/components/settings/coach-nudge-card";
import { CoachPrefsSection } from "@/components/settings/coach-prefs-section";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
-import { useTranslations } from "@/lib/i18n/context";
export function CoachSection() {
- const { t } = useTranslations();
const { isAuthenticated, user } = useAuth();
// Mirror the AI section's hydration-safe gating: the auth query can
// resolve before this boundary hydrates, so gate the child props on
@@ -42,22 +48,20 @@ export function CoachSection() {
const coachEnabled = !mounted || !user?.disableCoach;
return (
-
- {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
-
-
- {t("settings.sections.coach.title")}
-
-
-
+
{coachEnabled && }
+ {/* Proactive Coach nudge — moved here from Benachrichtigungen (v1.18.6).
+ Keeps the `#coach-nudge` anchor so existing deep links resolve. */}
+ {coachEnabled && (
+
+
+
+ )}
+
{coachEnabled && }
-
+
);
}
diff --git a/src/components/settings/dashboard-section.tsx b/src/components/settings/dashboard-section.tsx
index 70fa97477..8342fbb7c 100644
--- a/src/components/settings/dashboard-section.tsx
+++ b/src/components/settings/dashboard-section.tsx
@@ -1,29 +1,12 @@
"use client";
import { DashboardLayoutSection } from "@/components/settings/dashboard-layout-section";
-import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
-import { useTranslations } from "@/lib/i18n/context";
+/**
+ * v1.18.6 (W9) — the visible heading + subtitle now come from the shared
+ * ``, and the "← back to hub" link rides the frame's
+ * `topSlot`. The body is the layout customizer only.
+ */
export function DashboardSection() {
- const { t } = useTranslations();
-
- return (
-
-
-
-
- {t("settings.sections.dashboard.title")}
-
- {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
-
-
-
-
- );
+ return ;
}
diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx
index f6cf3d354..4116dd584 100644
--- a/src/components/settings/export-section.tsx
+++ b/src/components/settings/export-section.tsx
@@ -70,17 +70,11 @@ async function downloadFromUrl(url: string, filename: string): Promise {
export function ExportSection() {
const { t } = useTranslations();
+ // v1.18.6 (W9) — the visible page heading + subtitle now come from the
+ // shared `` in the route; the inner `
` keeps its
+ // own "other options" subsection label.
return (
-
-
-
- {t("settings.sections.export.title")}
-
-
-
+
{/* v1.18.0 (S5) — the full health-record export moved to its own
top-level "Gesundheitsakte" section. This page keeps the generic
CSV/JSON data-out paths and the import surface. */}
@@ -111,7 +105,7 @@ export function ExportSection() {
`export.zip` and the generic JSON paths. The export routes had
backends with no UI until now. */}
-
+
);
}
diff --git a/src/components/settings/gesundheitsakte-section.tsx b/src/components/settings/gesundheitsakte-section.tsx
index 7bf511e54..72ee9adac 100644
--- a/src/components/settings/gesundheitsakte-section.tsx
+++ b/src/components/settings/gesundheitsakte-section.tsx
@@ -18,32 +18,10 @@
*/
import { HealthRecordExportPanel } from "@/components/settings/health-record-export-panel";
-import { useTranslations } from "@/lib/i18n/context";
-import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
export function GesundheitsakteSection() {
- const { t } = useTranslations();
- return (
-
- {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
-
-
- {/* v1.18.6 — guided-tour re-entry for the doctor-report module + a
- real spotlight anchor (the section h1 is sr-only). */}
-
-
-
-
-
-
-
- );
+ // v1.18.6 (W9) — the visible heading + subtitle and the module tour-replay
+ // trigger now live in the shared `` in the route; the
+ // body is the health-record export panel only.
+ return ;
}
diff --git a/src/components/settings/insights-section.tsx b/src/components/settings/insights-section.tsx
index ffc434e4b..2fa49fa9d 100644
--- a/src/components/settings/insights-section.tsx
+++ b/src/components/settings/insights-section.tsx
@@ -2,8 +2,6 @@
import { InsightsOverviewArrangeSection } from "@/components/settings/insights-overview-arrange-section";
import { InsightsPillOrderSection } from "@/components/settings/insights-pill-order-section";
-import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
-import { useTranslations } from "@/lib/i18n/context";
/**
* v1.15.18 — the "Insights" settings section (analogous to the Dashboard
@@ -15,28 +13,16 @@ import { useTranslations } from "@/lib/i18n/context";
*
* Both write through the same `/api/insights/layout` contract the tab strip
* and the overview share, so a save here repaints both surfaces in lockstep.
+ *
+ * v1.18.6 (W9) — the visible "Insights" heading + subtitle now come from the
+ * shared ``, and the "← back to hub" link rides the
+ * frame's `topSlot`. The body is the two arrange cards only.
*/
export function InsightsSection() {
- const { t } = useTranslations();
-
return (
-
-
-
-
- {t("settings.sections.insights.title")}
-
- {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
-
-
+
-
+
);
}
diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx
index 2bd9fc16c..ef21a48bf 100644
--- a/src/components/settings/integrations-section.tsx
+++ b/src/components/settings/integrations-section.tsx
@@ -16,8 +16,6 @@
*/
import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel";
-import { useTranslations } from "@/lib/i18n/context";
-import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger";
export {
parseOAuthOutcome,
@@ -25,27 +23,8 @@ export {
} from "@/components/settings/integrations/connections-panel";
export function IntegrationsSection() {
- const { t } = useTranslations();
-
- return (
-
- {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */}
-
-
- {t("settings.sections.integrations.title")}
-
- {/* v1.18.6 — guided-tour re-entry for the integrations module. The
- visible affordance also gives the spotlight a real anchor (the
- section h1 is sr-only). */}
-
-
-
-
-
-
-
- );
+ // v1.18.6 (W9) — the visible heading + subtitle and the module tour-replay
+ // trigger now live in the shared `` in the route; the
+ // body is the connections panel only.
+ return ;
}
diff --git a/src/components/settings/layout-section.tsx b/src/components/settings/layout-section.tsx
index a636c39af..e5ad4528c 100644
--- a/src/components/settings/layout-section.tsx
+++ b/src/components/settings/layout-section.tsx
@@ -53,47 +53,38 @@ const LAYOUT_LINKS: ReadonlyArray = [
export function LayoutSection() {
const { t } = useTranslations();
+ // v1.18.6 (W9) — the visible heading + subtitle now come from the shared
+ // `` in the route; this body is the hub link list.
return (
-
-
-
);
}
diff --git a/src/components/settings/medications-section.tsx b/src/components/settings/medications-section.tsx
index c19d5c261..a1421aba9 100644
--- a/src/components/settings/medications-section.tsx
+++ b/src/components/settings/medications-section.tsx
@@ -73,17 +73,10 @@ export function MedicationsSection() {
),
].map((m) => ({ id: m.id, name: m.name, dose: m.dose, active: m.active }));
+ // v1.18.6 (W9) — the visible heading + subtitle now come from the shared
+ // `` in the route; this body is the medication cards.
return (
-
-
-
- {t("settings.sections.medications.title")}
-
-
-
+
{/* View preference — cards vs table. The shared header toggle
writes optimistically, so there is no Save button here. */}
-
+
);
}
diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx
index f90fff8f7..d52ed2507 100644
--- a/src/components/settings/modules-section.tsx
+++ b/src/components/settings/modules-section.tsx
@@ -32,12 +32,9 @@ import {
Droplet,
Dumbbell,
FileText,
- HeartPulse,
- Lock,
MessageCircleHeart,
Moon,
Pill,
- Scale,
Smile,
Sparkles,
TestTube,
@@ -52,10 +49,8 @@ import { useTranslations } from "@/lib/i18n/context";
import { apiPatch } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import {
- CORE_DOMAIN_KEYS,
MODULE_REGISTRY,
moduleDelegatesTo,
- type CoreDomainKey,
type ModuleKey,
} from "@/lib/modules/registry";
@@ -77,13 +72,6 @@ const MODULE_ICONS: Record = {
doctorReport: FileText,
};
-/** Neutral Lucide glyph per core (always-on) domain. */
-const CORE_ICONS: Record = {
- weight: Scale,
- bloodPressure: HeartPulse,
- pulse: Activity,
-};
-
export function ModulesSection() {
const { t } = useTranslations();
const { user } = useAuth();
@@ -113,103 +101,65 @@ export function ModulesSection() {
},
});
+ // v1.18.6 (W9) — the visible heading + subtitle now come from the shared
+ // `` (the former muted-`
` intro folded into the
+ // frame's standard subtitle). The "Module immer aktiv" core-domains card
+ // was removed: a domain that can't be turned off doesn't need to be listed.
return (
-
- {/* v1.18.6 — restore a short intro line so a new user understands
- that this page drives the nav, tiles and assessments they see.
- (Dropped in v1.18.1 D0 for top alignment; the orientation cost
- outweighed the alignment.) */}
-
-
- {t("settings.sections.modules.title")}
-
-
- {t("settings.sections.modules.description")}
-
-
-
-
-
- {/* v1.18.6 — restore the one-line "what this section does" hint. */}
-
+ {(Object.keys(MODULE_REGISTRY) as ModuleKey[]).map((key) => {
+ const def = MODULE_REGISTRY[key];
+ // Default-on: a module is enabled unless explicitly `false`.
+ const enabled = modules[key] !== false;
+ // Delegated modules (cycle/coach) are owned elsewhere — never a
+ // live toggle here; deep-link to their real control instead.
+ const delegated = moduleDelegatesTo(key) !== undefined;
+ // Operator turned this module off server-wide: no per-user
+ // toggle can re-enable it, so show a read-only note. Delegated
+ // rows already deep-link, so this only matters for owned keys.
+ const operatorDisabled =
+ !delegated && moduleAvailability[key] === false;
+ return (
toggle.mutate({ key, enabled: next })}
/>
- ))}
-
);
+ // v1.18.6 (W9) — the visible heading + subtitle now come from the shared
+ // `` in the route; this body is the mood-tag cards.
return (
-
-
-
- {t("settings.sections.mood.title")}
-
-
-
+
{/* Groups — picker group order + own groups. */}
)}
-
+
);
}
diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx
index ff979d0c5..edeeb8df3 100644
--- a/src/components/settings/notifications-section.tsx
+++ b/src/components/settings/notifications-section.tsx
@@ -1,8 +1,6 @@
"use client";
import { useAuth } from "@/hooks/use-auth";
-import { useTranslations } from "@/lib/i18n/context";
-import { CoachNudgeCard } from "@/components/settings/coach-nudge-card";
import { LowStockCard } from "@/components/settings/low-stock-card";
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
@@ -17,44 +15,31 @@ import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
*
* Each reminder-type card is shown ONLY when its module is enabled, read from
* the resolved `useAuth().user.modules` map (the same `/auth/me` map nav and
- * Insights pills gate off). Mood → `mood`, Coach nudge → `coach`, low-stock
- * runway → `medications` (a toggleable fail-open module since D3, so a missing
- * key reads as enabled and a stale `/me` payload never blanks a card).
+ * Insights pills gate off). Mood → `mood`, low-stock runway → `medications`
+ * (a toggleable fail-open module since D3, so a missing key reads as enabled
+ * and a stale `/me` payload never blanks a card).
*
* v1.18.1 (D5) — the page is intentionally lean: the section blurb, the
* channels / inbox cross-links, and the embedded Vorsorge (preventive-care)
* editor were all removed. Vorsorge has its own `/vorsorge` page, so embedding
* it here was a duplicate. Delivery CHANNELS (Telegram / ntfy / Web Push /
* Webhook / Email) live under Settings → Integrations → Channels.
+ *
+ * v1.18.6 (W9) — the visible heading + subtitle now come from the shared
+ * ``. The proactive-Coach nudge card moved to Settings →
+ * Coach (it is a Coach setting, not a generic notification), so this screen
+ * carries the mood + low-stock reminder cards only.
*/
export function NotificationsSection() {
- const { t } = useTranslations();
const { isAuthenticated, user } = useAuth();
// v1.18.0 (S4) — module-gated per-type visibility. Fail OPEN (`!== false`)
// so a stale `/me` payload without the module map keeps every card visible.
const moodEnabled = user?.modules?.mood !== false;
- const coachEnabled = user?.modules?.coach !== false;
const medsEnabled = user?.modules?.medications !== false;
return (
-
- {/* v1.18.1 (D0/D5) — the section blurb and the channels / inbox cross-
- links were dropped: the header now starts at the same height as every
- other section and the page carries only the reminder-type cards. */}
-
-
- {t("settings.sections.notifications.title")}
-
-
-
- {/* v1.18.1 (D5) — the Vorsorge (preventive-care) block was removed; it
- has its own /vorsorge page, so embedding the editor here was a
- duplicate. This screen keeps the three reminder-type cards below. */}
-
+
{/* Mood check-in reminder — only when the mood module is enabled. */}
{moodEnabled ? (
@@ -69,13 +54,6 @@ export function NotificationsSection() {
) : null}
-
- {/* Proactive Coach nudge — only when the coach module is enabled. */}
- {coachEnabled ? (
-
-
-
- ) : null}
-
+
);
}
diff --git a/src/components/settings/section-nav-heading-spacer.tsx b/src/components/settings/section-nav-heading-spacer.tsx
new file mode 100644
index 000000000..5a5bcf962
--- /dev/null
+++ b/src/components/settings/section-nav-heading-spacer.tsx
@@ -0,0 +1,25 @@
+/**
+ * `` — invisible spacer that reserves exactly the
+ * height of a section's heading block (h1 + subtitle) plus the `space-y-6`
+ * gap that separates the heading from the first card.
+ *
+ * v1.18.6 (W9) — Marc's symmetry ask: the left settings / admin menu's first
+ * item must line up with the TOP OF THE FIRST CARD, not with the page
+ * heading. The page heading lives in the content column above the first card,
+ * so the sidebar needs to start one heading-block lower. Replicating the
+ * exact heading markup (same `text-2xl` h1 + `text-sm` subtitle) as a hidden,
+ * `aria-hidden` block keeps the offset pixel-accurate across locales and
+ * line-wrapping — a hard-coded `pt-[…]` would drift the moment a title or
+ * subtitle wraps to a second line.
+ *
+ * It is only rendered on `md+` (the desktop sticky sidebar); the mobile chip
+ * strip has no heading-to-align-with.
+ */
+export function SectionNavHeadingSpacer() {
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/settings/settings-section-frame.tsx b/src/components/settings/settings-section-frame.tsx
new file mode 100644
index 000000000..1191d1cb0
--- /dev/null
+++ b/src/components/settings/settings-section-frame.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+/**
+ * `` — the canonical page-frame for every Settings
+ * section. The settings twin of the admin renderer's `SectionFrame`
+ * (`src/app/admin/[section]/renderer.tsx`): it emits the one visible
+ * heading + subheading every page must show, in the reference style used by
+ * the Admin Konsole → Übersicht landing.
+ *
+ * Before this primitive every settings section hand-rolled its own
+ * `sr-only` `
`, so no settings page showed a visible heading and the
+ * left-menu-to-first-card alignment drifted page to page. Routing all 19
+ * sections through one frame fixes both: a constant-height heading block
+ * above the first card, and a real visible `h1 + subtitle`.
+ *
+ *
+ * {cards}
+ *
+ *
+ * Title comes from `settings.sections..title`; subtitle from
+ * `settings.sections..subtitle`. The `
` keeps the historic
+ * `settings-section--title` id so the section's own `aria-labelledby`
+ * linkage — and the spotlight-tour anchors that reference it — stay intact.
+ *
+ * `topSlot` renders ABOVE the heading (used by the Layout-hub child
+ * editors for their "← back to hub" link, which must sit above the title).
+ * `headingAccessory` renders inline at the top-right of the heading row
+ * (used by Gesundheitsakte for its guided-tour re-entry trigger).
+ */
+
+import * as React from "react";
+
+import { useTranslations } from "@/lib/i18n/context";
+import type { SettingsSectionSlug } from "./section-slugs";
+
+export interface SettingsSectionFrameProps {
+ slug: SettingsSectionSlug;
+ /** Optional content rendered above the heading (e.g. a back-to-hub link). */
+ topSlot?: React.ReactNode;
+ /** Optional content rendered inline at the top-right of the heading row
+ * (e.g. a guided-tour re-entry trigger). */
+ headingAccessory?: React.ReactNode;
+ children: React.ReactNode;
+}
+
+export function SettingsSectionFrame({
+ slug,
+ topSlot,
+ headingAccessory,
+ children,
+}: SettingsSectionFrameProps) {
+ const { t } = useTranslations();
+ const titleId = `settings-section-${slug}-title`;
+
+ return (
+
+ {topSlot ?
{topSlot}
: null}
+
+
+
+ {t(`settings.sections.${slug}.title`)}
+
+
+ {t(`settings.sections.${slug}.subtitle`)}
+
+
+ {headingAccessory ? (
+
{headingAccessory}
+ ) : null}
+
+ {children}
+
+ );
+}
diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx
index e47b640a4..f99b74e2f 100644
--- a/src/components/settings/settings-shell.tsx
+++ b/src/components/settings/settings-shell.tsx
@@ -48,6 +48,7 @@ import {
isSettingsSectionSlug,
type SettingsSectionSlug,
} from "./section-slugs";
+import { SectionNavHeadingSpacer } from "./section-nav-heading-spacer";
// Re-export so existing call-sites importing from this module keep working.
// New server-side call-sites (e.g. `generateStaticParams()`) should import
@@ -259,8 +260,7 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
// no `moduleGate` (global / CORE) are always shown.
const modules = user?.modules;
const visibleSections = SETTINGS_SECTIONS.filter(
- (section) =>
- !section.moduleGate || modules?.[section.moduleGate] !== false,
+ (section) => !section.moduleGate || modules?.[section.moduleGate] !== false,
);
// v1.4.33 IW4 — keep the active chip in view inside the horizontal
@@ -358,6 +358,10 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
className="hidden md:block"
>
+ {/* v1.18.6 (W9) — push the first nav item down to the top of the
+ first card (level with the page heading's following gap), so
+ the menu reads symmetric with the content column. */}
+
{visibleSections.map((section) => {
const isActive = section.slug === activeSlug;
diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx
index 077b6de04..9a714b156 100644
--- a/src/components/settings/sources-section.tsx
+++ b/src/components/settings/sources-section.tsx
@@ -278,31 +278,13 @@ export function SourcesSection() {
return Object.keys(priority.deviceTypePriority).length;
}, [priority]);
+ // v1.18.6 (W9) — the visible heading + subtitle now come from the shared
+ // `` (the off-style muted intro folded into the
+ // standard subtitle). The quiet cross-link back to Integrationen — where
+ // the sources this ladder ranks are added / removed — stays inside the
+ // card, below the ladder.
return (
-
- {/* v1.18.1 (D0/D4) — standalone left-side entry again. The section
- blurb is dropped for consistent top alignment; the cross-link back
- to Integrationen (where the sources this ladder ranks are added /
- removed) stays as a quiet pointer. */}
-
-
)}
+
+ {/* v1.18.6 (W9) — quiet pointer back to Integrationen, where the
+ sources this ladder ranks are connected / removed. Folded into
+ the card so the page top stays the standard heading + subtitle. */}
+
+ {grid}
);
}
@@ -206,6 +308,7 @@ export function IllnessView() {
const { t } = useTranslations();
const { data: episodes, isLoading } = useIllnessEpisodes(true);
const resolve = useResolveEpisode();
+ const { prefs } = useModuleListPrefs("illness");
const today = todayLocal();
const [newOpen, setNewOpen] = useState(false);
@@ -248,8 +351,14 @@ export function IllnessView() {
}
}
- return { activeParents, resolvedParents, childrenByParent };
- }, [episodes]);
+ return {
+ // v1.18.6 (MOD-03) — honour the user's persisted manual order within
+ // each status group (ids absent from the order sort after the block).
+ activeParents: applyOrder(activeParents, prefs.order, (e) => e.id),
+ resolvedParents: applyOrder(resolvedParents, prefs.order, (e) => e.id),
+ childrenByParent,
+ };
+ }, [episodes, prefs.order]);
const hasEpisodes = (episodes?.length ?? 0) > 0;
@@ -269,17 +378,37 @@ export function IllnessView() {
+ {/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
+ Illness settings page (view + reorder). */}
+
+
+
+
+
+ {/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every
+ other module, not the bespoke "neue Episode". */}
setNewOpen(true)}
className="min-h-11 sm:min-h-9"
>
- {t("illness.newEpisode")}
+ {t("common.add")}
-
{t("illness.disclaimer")}
+ {/* v1.18.6 (DISC-01) — the per-page medical disclaimer line is removed;
+ the one-time acknowledgment now lives at onboarding and the legal
+ text stays reachable on the public privacy page. */}
{isLoading ? (
@@ -297,6 +426,7 @@ export function IllnessView() {
parents={grouped.activeParents}
childrenByParent={grouped.childrenByParent}
resolving={resolve.isPending}
+ view={prefs.view}
onLogDay={(id) => setLogEpisodeId(id)}
onEdit={(e) => setEditEpisode(e)}
onResolve={(id) => resolve.mutate(id)}
@@ -306,6 +436,8 @@ export function IllnessView() {
parents={grouped.resolvedParents}
childrenByParent={grouped.childrenByParent}
resolving={resolve.isPending}
+ view={prefs.view}
+ collapsible
onLogDay={(id) => setLogEpisodeId(id)}
onEdit={(e) => setEditEpisode(e)}
onResolve={(id) => resolve.mutate(id)}
diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx
index d53b69a68..7c31e6351 100644
--- a/src/components/labs/lab-list.tsx
+++ b/src/components/labs/lab-list.tsx
@@ -20,6 +20,7 @@ import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
+import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
import { LabTrendSparkline } from "./lab-trend-sparkline";
import { ReferenceRangeBadge } from "./reference-range-badge";
@@ -79,6 +80,7 @@ function groupReadings(results: LabResultDto[]): MarkerGroup[] {
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
const { t } = useTranslations();
+ const { prefs } = useModuleListPrefs("labs");
const listKey = queryKeys.labResultsList({
biomarkerId: undefined,
@@ -96,10 +98,18 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
apiGet("/api/labs?limit=500&sortDir=desc"),
});
- const groups = useMemo(
- () => groupReadings(data?.results ?? []),
- [data?.results],
- );
+ const groups = useMemo(() => {
+ const base = groupReadings(data?.results ?? []);
+ // v1.18.6 (MOD-04) — honour the user's Labs sort choice. `groupReadings`
+ // already returns most-recent-first; `recentAsc` reverses, and `manual`
+ // applies the persisted biomarker order (legacy un-linked groups, which
+ // carry no biomarkerId, sort after the ordered block).
+ if (prefs.sortDir === "recentAsc") return [...base].reverse();
+ if (prefs.sortDir === "manual") {
+ return applyOrder(base, prefs.order, (g) => g.biomarkerId ?? g.key);
+ }
+ return base;
+ }, [data?.results, prefs.sortDir, prefs.order]);
// The list caps at 500 rows server-side (the `limit` ceiling). Surface a
// calm "showing latest N of M" hint when the cap truncates so the count is
@@ -149,6 +159,65 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
);
}
+ // v1.18.6 (MOD-03) — compact list view: one bordered card holding tight
+ // divided rows instead of a card per biomarker. The card view stays the
+ // default; this is the denser alternative the settings toggle selects.
+ if (prefs.view === "list") {
+ return (
+
+
+
+ {deleteDialog}
+ >
+ );
+ }
+
+ // Next/last block in the med-card grammar, but with MEASUREMENT wording
+ // (MOD-06: never "Einnahme" / intake — a measurement is not an intake). The
+ // "next" value carries the discreet neutral due badge; the "last" value is
+ // the last satisfied date.
const lastValue = reminder.lastSatisfiedAt
? fmt.date(new Date(reminder.lastSatisfiedAt))
: null;
+ const nextLastSlot = (
+
+ );
return (
<>
- {/* NEUTRAL card — no status-driven colour. Due state reads only through
- the discreet badge below. Shares the medication card shell + tokens. */}
-
-
-
-
+ {/* NEUTRAL card — no status-driven colour. Due state reads only through
+ the discreet badge + the green action button below (MOD-06). Shares
+ the medication card shell + tokens. */}
+
+
+
+ {nextLastSlot}
- {reminder.endsOn && (
-
+ )}
- {/* Bottom-pinned single primary action — branches on the link state:
- a self-planned exam marks done; a measurement-linked reminder
- opens the value-entry form. */}
-
+ );
+}
diff --git a/src/components/module-list/module-settings-frame.tsx b/src/components/module-list/module-settings-frame.tsx
new file mode 100644
index 000000000..12c6887d1
--- /dev/null
+++ b/src/components/module-list/module-settings-frame.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import type { ReactNode } from "react";
+
+import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
+
+/**
+ * v1.18.6 (W8 / MOD-03) — heading frame for the per-module settings pages
+ * (Vorsorge / Illness / Labs).
+ *
+ * FRAME-ADOPTION SEAM: a parallel phase is introducing a shared
+ * `SettingsSectionFrame` primitive under `src/components/settings/*` (which
+ * this phase must not edit). Until that lands, this renders the canonical
+ * settings heading markup verbatim — `h1.text-2xl.font-bold.tracking-tight`
+ * + `p.text-muted-foreground.text-sm` — so the three new pages already
+ * conform to the standard and can be swapped to the shared frame at merge by
+ * replacing this single wrapper. The back-link reuses the existing
+ * `SettingsHubBackLink` (read-only import) so the pages have a consistent
+ * "return to the hub" affordance even though they are not listed in the
+ * settings-shell sidebar.
+ */
+export function ModuleSettingsFrame({
+ title,
+ description,
+ backHref,
+ backLabelKey,
+ children,
+}: {
+ title: string;
+ description: string;
+ /** Where the "← back" link returns to — the module's own page. */
+ backHref: string;
+ backLabelKey: string;
+ children: ReactNode;
+}) {
+ return (
+
+
+
+
{title}
+
{description}
+
+ {children}
+
+ );
+}
diff --git a/src/components/module-list/module-view-toggle.tsx b/src/components/module-list/module-view-toggle.tsx
new file mode 100644
index 000000000..2fc25c0cb
--- /dev/null
+++ b/src/components/module-list/module-view-toggle.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { LayoutGrid, List as ListIcon } from "lucide-react";
+
+import { useTranslations } from "@/lib/i18n/context";
+import { cn } from "@/lib/utils";
+import type { ModuleListView } from "@/lib/module-list-prefs";
+
+/**
+ * v1.18.6 (W8 / MOD-03) — icon-only card/list view toggle shared by the
+ * Vorsorge / Illness / Labs settings pages. Mirrors the medication module's
+ * `MedicationViewToggle` grammar (two ghost segments, the active one filled,
+ * `aria-pressed` per segment) but switches between a card grid and a compact
+ * list rather than cards-vs-table, so the label set is `moduleList.view*`.
+ */
+interface ModuleViewToggleProps {
+ view: ModuleListView;
+ onChange: (view: ModuleListView) => void;
+}
+
+export function ModuleViewToggle({ view, onChange }: ModuleViewToggleProps) {
+ const { t } = useTranslations();
+
+ const segments = [
+ {
+ value: "cards" as const,
+ label: t("moduleList.viewCards"),
+ icon: LayoutGrid,
+ },
+ {
+ value: "list" as const,
+ label: t("moduleList.viewList"),
+ icon: ListIcon,
+ },
+ ];
+
+ return (
+
+ );
+}
diff --git a/src/lib/__tests__/module-list-prefs.test.ts b/src/lib/__tests__/module-list-prefs.test.ts
new file mode 100644
index 000000000..8434b9615
--- /dev/null
+++ b/src/lib/__tests__/module-list-prefs.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from "vitest";
+
+import { applyOrder, parseModuleListPrefs } from "@/lib/module-list-prefs";
+
+describe("module-list-prefs", () => {
+ describe("parseModuleListPrefs", () => {
+ it("returns defaults for null / malformed input", () => {
+ expect(parseModuleListPrefs(null)).toEqual({
+ view: "cards",
+ order: [],
+ sortDir: "recentDesc",
+ });
+ expect(parseModuleListPrefs("{not json")).toEqual({
+ view: "cards",
+ order: [],
+ sortDir: "recentDesc",
+ });
+ });
+
+ it("coerces unknown view / sortDir to defaults but keeps a valid order", () => {
+ const parsed = parseModuleListPrefs(
+ JSON.stringify({ view: "grid", sortDir: "weird", order: ["a", "b"] }),
+ );
+ expect(parsed).toEqual({
+ view: "cards",
+ sortDir: "recentDesc",
+ order: ["a", "b"],
+ });
+ });
+
+ it("round-trips valid values", () => {
+ const parsed = parseModuleListPrefs(
+ JSON.stringify({ view: "list", sortDir: "manual", order: ["x"] }),
+ );
+ expect(parsed).toEqual({
+ view: "list",
+ sortDir: "manual",
+ order: ["x"],
+ });
+ });
+ });
+
+ describe("applyOrder", () => {
+ const items = [{ id: "a" }, { id: "b" }, { id: "c" }];
+ const idOf = (i: { id: string }) => i.id;
+
+ it("returns the input order when no order is persisted", () => {
+ expect(applyOrder(items, [], idOf)).toEqual(items);
+ });
+
+ it("places ordered ids first, then the server-default tail", () => {
+ const result = applyOrder(items, ["c", "a"], idOf);
+ expect(result.map(idOf)).toEqual(["c", "a", "b"]);
+ });
+
+ it("ignores ids in the order that are absent from the data", () => {
+ const result = applyOrder(items, ["z", "b"], idOf);
+ expect(result.map(idOf)).toEqual(["b", "a", "c"]);
+ });
+ });
+});
diff --git a/src/lib/module-list-prefs.ts b/src/lib/module-list-prefs.ts
new file mode 100644
index 000000000..c23a396ce
--- /dev/null
+++ b/src/lib/module-list-prefs.ts
@@ -0,0 +1,181 @@
+"use client";
+
+/**
+ * v1.18.6 (W8 / MOD-03) — client-side per-module list preferences for the
+ * three secondary modules (Vorsorge, Illness, Labs).
+ *
+ * These modules do not carry a server-side list-layout column the way
+ * Medications does (`/api/medications/layout`). Rather than mint three new
+ * Prisma columns + routes — which a parallel backend phase owns — the
+ * reorder + view choice live in `localStorage`, keyed per module. The choice
+ * is presentational only: it reorders / reshapes what the page already
+ * fetched and never feeds a server read, so a missing or stale blob degrades
+ * to the server's own default order.
+ *
+ * Shape per module (one localStorage key each):
+ * { view: "cards" | "list", order: string[] }
+ * `order` is an id allow-list; ids absent from the live data are ignored and
+ * ids present in the data but absent from `order` sort after the ordered
+ * block (the server default order is preserved for the tail).
+ */
+
+import { useCallback, useSyncExternalStore } from "react";
+
+export const MODULE_LIST_VIEWS = ["cards", "list"] as const;
+export type ModuleListView = (typeof MODULE_LIST_VIEWS)[number];
+
+export type ModuleListKey = "vorsorge" | "illness" | "labs";
+
+/**
+ * Sort choice for the Labs surface (MOD-04): order biomarker groups by the
+ * recency of their most-recent reading. `manual` defers to the persisted
+ * `order` array instead.
+ */
+export const MODULE_SORT_DIRS = ["recentDesc", "recentAsc", "manual"] as const;
+export type ModuleSortDir = (typeof MODULE_SORT_DIRS)[number];
+
+export interface ModuleListPrefs {
+ view: ModuleListView;
+ order: string[];
+ /** Only consumed by the Labs surface; defaults to most-recent-first. */
+ sortDir: ModuleSortDir;
+}
+
+const DEFAULT_PREFS: ModuleListPrefs = {
+ view: "cards",
+ order: [],
+ sortDir: "recentDesc",
+};
+
+function storageKey(module: ModuleListKey): string {
+ return `healthlog:module-list-prefs:${module}`;
+}
+
+function isView(value: unknown): value is ModuleListView {
+ return (
+ typeof value === "string" &&
+ (MODULE_LIST_VIEWS as readonly string[]).includes(value)
+ );
+}
+
+function isSortDir(value: unknown): value is ModuleSortDir {
+ return (
+ typeof value === "string" &&
+ (MODULE_SORT_DIRS as readonly string[]).includes(value)
+ );
+}
+
+export function parseModuleListPrefs(raw: string | null): ModuleListPrefs {
+ if (!raw) return DEFAULT_PREFS;
+ try {
+ const blob = JSON.parse(raw) as Record;
+ const view = isView(blob.view) ? blob.view : DEFAULT_PREFS.view;
+ const order = Array.isArray(blob.order)
+ ? blob.order.filter((v): v is string => typeof v === "string")
+ : [];
+ const sortDir = isSortDir(blob.sortDir)
+ ? blob.sortDir
+ : DEFAULT_PREFS.sortDir;
+ return { view, order, sortDir };
+ } catch {
+ return DEFAULT_PREFS;
+ }
+}
+
+/**
+ * Apply a persisted id order to a live list. Items whose id appears in
+ * `order` come first in that order; everything else keeps the input order
+ * (the server default) after the ordered block. Ids in `order` but absent
+ * from the data are silently dropped.
+ */
+export function applyOrder(
+ items: readonly T[],
+ order: readonly string[],
+ idOf: (item: T) => string,
+): T[] {
+ if (order.length === 0) return [...items];
+ const rank = new Map(order.map((id, i) => [id, i]));
+ const ordered: T[] = [];
+ const rest: T[] = [];
+ for (const item of items) {
+ if (rank.has(idOf(item))) ordered.push(item);
+ else rest.push(item);
+ }
+ ordered.sort((a, b) => rank.get(idOf(a))! - rank.get(idOf(b))!);
+ return [...ordered, ...rest];
+}
+
+// A module-scoped event so every mounted hook for the same module repaints
+// when one writer flushes a change (the settings page + the module page can
+// both be alive across a client navigation without a full reload).
+const listeners = new Set<() => void>();
+function emit() {
+ for (const l of listeners) l();
+}
+
+/**
+ * Read + write the per-module list preferences. SSR-safe via
+ * `useSyncExternalStore` with a null server snapshot (parsed to defaults).
+ */
+export function useModuleListPrefs(module: ModuleListKey): {
+ prefs: ModuleListPrefs;
+ setView: (view: ModuleListView) => void;
+ setOrder: (order: string[]) => void;
+ setSortDir: (sortDir: ModuleSortDir) => void;
+} {
+ const key = storageKey(module);
+
+ const subscribe = useCallback(
+ (onChange: () => void) => {
+ listeners.add(onChange);
+ const onStorage = (e: StorageEvent) => {
+ if (e.key === null || e.key === key) onChange();
+ };
+ if (typeof window !== "undefined") {
+ window.addEventListener("storage", onStorage);
+ }
+ return () => {
+ listeners.delete(onChange);
+ if (typeof window !== "undefined") {
+ window.removeEventListener("storage", onStorage);
+ }
+ };
+ },
+ [key],
+ );
+
+ const getSnapshot = useCallback(() => {
+ if (typeof window === "undefined") return null;
+ return window.localStorage.getItem(key);
+ }, [key]);
+
+ const raw = useSyncExternalStore(subscribe, getSnapshot, () => null);
+ const prefs = parseModuleListPrefs(raw);
+
+ const write = useCallback(
+ (next: ModuleListPrefs) => {
+ if (typeof window === "undefined") return;
+ window.localStorage.setItem(key, JSON.stringify(next));
+ emit();
+ },
+ [key],
+ );
+
+ const setView = useCallback(
+ (view: ModuleListView) =>
+ write({ ...parseModuleListPrefs(getSnapshot()), view }),
+ [write, getSnapshot],
+ );
+ const setOrder = useCallback(
+ (order: string[]) =>
+ write({ ...parseModuleListPrefs(getSnapshot()), order }),
+ [write, getSnapshot],
+ );
+ const setSortDir = useCallback(
+ (sortDir: ModuleSortDir) =>
+ write({ ...parseModuleListPrefs(getSnapshot()), sortDir }),
+ [write, getSnapshot],
+ );
+
+ return { prefs, setView, setOrder, setSortDir };
+}
From 0a8ccaa30c51901814e00b98854556587ae2e8fb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 21:37:13 +0200
Subject: [PATCH 54/71] feat(onboarding): one-time medical-disclaimer
acknowledgment
Replace the per-page and per-chart disclaimer banners with a single
acknowledgment at the start of onboarding: HealthLog is a private tracking
tool, not a clinical assessment or diagnosis; talk to a doctor if worried.
- record the acknowledgment per user (disclaimer_acknowledged_at/_version,
migration 0175) mirroring the research-mode acknowledgment precedent
- gate the welcome "Get started" on the checkbox; expose the timestamp on
/api/auth/me so a returning user is never re-asked
- keep the full legal text reachable on the public privacy page
---
.../0175_v1186_disclaimer_ack/migration.sql | 18 ++++++
prisma/schema.prisma | 11 ++++
src/app/api/auth/me/route.ts | 4 ++
src/app/api/onboarding/disclaimer/route.ts | 61 +++++++++++++++++++
src/components/onboarding/WelcomeCarousel.tsx | 51 +++++++++++++++-
src/hooks/use-auth.ts | 9 +++
src/lib/onboarding/disclaimer.ts | 10 +++
7 files changed, 162 insertions(+), 2 deletions(-)
create mode 100644 prisma/migrations/0175_v1186_disclaimer_ack/migration.sql
create mode 100644 src/app/api/onboarding/disclaimer/route.ts
create mode 100644 src/lib/onboarding/disclaimer.ts
diff --git a/prisma/migrations/0175_v1186_disclaimer_ack/migration.sql b/prisma/migrations/0175_v1186_disclaimer_ack/migration.sql
new file mode 100644
index 000000000..389c580ec
--- /dev/null
+++ b/prisma/migrations/0175_v1186_disclaimer_ack/migration.sql
@@ -0,0 +1,18 @@
+-- v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment.
+--
+-- Two nullable columns on `users` recording that the user acknowledged, once
+-- at the start of onboarding, that HealthLog is a private tracking tool — not
+-- a clinical assessment or diagnosis — and that they should talk to a doctor
+-- if worried. The `*_version` pins exactly which copy was read (mirrors the
+-- `research_mode_acknowledged_*` pair), so a future material wording change
+-- can re-prompt without a schema change.
+--
+-- Both nullable with no default: NULL = never acknowledged. The prompt only
+-- gates fresh onboarding, so existing accounts (backfilled to NULL) are never
+-- re-walled. This replaces the per-page / per-chart disclaimer banners that
+-- were removed app-wide in the same release; the acknowledged legal text
+-- stays reachable on the public privacy page.
+
+ALTER TABLE "users"
+ ADD COLUMN "disclaimer_acknowledged_at" TIMESTAMP(3),
+ ADD COLUMN "disclaimer_acknowledged_version" TEXT;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index f87097c6e..b4de47a28 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -279,6 +279,17 @@ model User {
// null on a Settings replay so the tour genuinely restarts.
onboardingTourProgressJson Json? @map("onboarding_tour_progress_json")
+ // v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. Shown
+ // once at the start of onboarding: HealthLog is a private tracking tool,
+ // not a clinical assessment or diagnosis; if worried, talk to a doctor.
+ // Mirrors the `researchModeAcknowledged*` pair — the version pins exactly
+ // which copy the user read, so a future material wording change can ask
+ // again. Null = never acknowledged (legacy accounts read as never-asked;
+ // the prompt only gates fresh onboarding, so they are not re-walled). The
+ // acknowledged legal text stays reachable on the public privacy page.
+ disclaimerAcknowledgedAt DateTime? @map("disclaimer_acknowledged_at")
+ disclaimerAcknowledgedVersion String? @map("disclaimer_acknowledged_version")
+
// HealthKit (iOS) integration config — list of per-metric direction toggles.
// Shape: { entries: [{ id, kind, direction, enabled }], lastSyncedAt }
healthKitConfigJson Json? @map("healthkit_config_json")
diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts
index d23f63b8e..434f29221 100644
--- a/src/app/api/auth/me/route.ts
+++ b/src/app/api/auth/me/route.ts
@@ -71,6 +71,10 @@ export const GET = apiHandler(async () => {
timezone: user.timezone,
onboardingCompletedAt: user.onboardingCompletedAt,
onboardingTourCompleted: user.onboardingTourCompleted,
+ // v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. Null
+ // when never acknowledged; the onboarding welcome gate reads this to
+ // decide whether to require the acknowledgment before "Get started".
+ disclaimerAcknowledgedAt: user.disclaimerAcknowledgedAt,
// v1.18.6 — resumable module-tour progress. Null when the user has
// not started the tour; otherwise the resume point the launcher
// seeds its index from. Fail-soft parse: a corrupt blob degrades to
diff --git a/src/app/api/onboarding/disclaimer/route.ts b/src/app/api/onboarding/disclaimer/route.ts
new file mode 100644
index 000000000..5422adb60
--- /dev/null
+++ b/src/app/api/onboarding/disclaimer/route.ts
@@ -0,0 +1,61 @@
+import { z } from "zod/v4";
+
+import { prisma } from "@/lib/db";
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { annotate } from "@/lib/logging/context";
+import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
+import { DISCLAIMER_VERSION } from "@/lib/onboarding/disclaimer";
+
+/**
+ * v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment.
+ *
+ * POST { version: string }
+ * → stamps `users.disclaimer_acknowledged_at = now()` +
+ * `disclaimer_acknowledged_version = `. Idempotent: a repeat
+ * acknowledgment of the same version simply refreshes the timestamp.
+ *
+ * Replaces the per-page / per-chart disclaimer banners removed app-wide in
+ * the same release. The acknowledged legal text stays reachable on the public
+ * privacy page. Never auto-set; the onboarding welcome step is the one writer.
+ */
+
+const bodySchema = z.object({
+ // Echoed back from the client so a stale shell cannot silently record an
+ // acknowledgment of copy it never rendered. Bounded; the server pins the
+ // canonical version it persists regardless.
+ version: z.string().min(1).max(64),
+});
+
+export const POST = apiHandler(async (request) => {
+ const { user } = await requireAuth();
+
+ const { data: rawBody, error: jsonError } = await safeJson(request, {
+ maxBytes: 4 * 1024,
+ });
+ if (jsonError) return jsonError;
+
+ const parsed = z.safeParse(bodySchema, rawBody);
+ if (!parsed.success) {
+ return apiError("Invalid input", 422);
+ }
+
+ // The server pins the canonical version it stamps — the body version is
+ // only a freshness signal, never the source of truth.
+ await prisma.user.update({
+ where: { id: user.id },
+ data: {
+ disclaimerAcknowledgedAt: new Date(),
+ disclaimerAcknowledgedVersion: DISCLAIMER_VERSION,
+ },
+ });
+
+ annotate({
+ action: { name: "onboarding.disclaimer.acknowledge" },
+ meta: {
+ version: DISCLAIMER_VERSION,
+ clientVersion: parsed.data.version,
+ },
+ });
+
+ return apiSuccess({ acknowledgedVersion: DISCLAIMER_VERSION });
+});
diff --git a/src/components/onboarding/WelcomeCarousel.tsx b/src/components/onboarding/WelcomeCarousel.tsx
index 937e0fa0d..986a666f7 100644
--- a/src/components/onboarding/WelcomeCarousel.tsx
+++ b/src/components/onboarding/WelcomeCarousel.tsx
@@ -13,11 +13,16 @@ import {
type LucideIcon,
} from "lucide-react";
+import Link from "next/link";
+
import { Button } from "@/components/ui/button";
+import { Checkbox } from "@/components/ui/checkbox";
+import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { scrollBehaviorForUser } from "@/lib/motion";
import { cn } from "@/lib/utils";
import { apiPost } from "@/lib/api/api-fetch";
+import { DISCLAIMER_VERSION } from "@/lib/onboarding/disclaimer";
/**
* v1.4.25 W14b-Content — onboarding welcome carousel.
@@ -72,11 +77,19 @@ export function WelcomeCarousel() {
const { t } = useTranslations();
const router = useRouter();
const queryClient = useQueryClient();
+ const { user } = useAuth();
const railRef = useRef(null);
const liveRegionId = useId();
+ const disclaimerId = useId();
const [active, setActive] = useState(0);
const [advancing, setAdvancing] = useState(false);
+ // v1.18.6 (DISC-02) — the one-time medical-disclaimer acknowledgment gates
+ // "Get started". Pre-checked for an account that already acknowledged (a
+ // re-walk of step 0) so a returning user is not re-asked.
+ const [acknowledged, setAcknowledged] = useState(
+ () => user?.disclaimerAcknowledgedAt != null,
+ );
// Track which slide is centred in the rail. We observe each slide
// with an IntersectionObserver — simpler and more reliable than
@@ -128,9 +141,16 @@ export function WelcomeCarousel() {
}, []);
async function handleGetStarted() {
- if (advancing) return;
+ if (advancing || !acknowledged) return;
setAdvancing(true);
try {
+ // v1.18.6 (DISC-02) — record the one-time acknowledgment before
+ // advancing. Skipped when the account already acknowledged (re-walk).
+ if (user?.disclaimerAcknowledgedAt == null) {
+ await apiPost("/api/onboarding/disclaimer", {
+ version: DISCLAIMER_VERSION,
+ });
+ }
await apiPost("/api/onboarding/step", { step: 1 });
await queryClient.invalidateQueries({ queryKey: ["auth"] });
router.push("/onboarding/1");
@@ -273,12 +293,39 @@ export function WelcomeCarousel() {
+ {/* v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. This
+ replaces the per-page / per-chart disclaimer banners removed app-wide
+ in the same release; the full legal text stays reachable on the
+ public privacy page (link below). */}
+
From 04a50b2702d5b6cefa51371c571173223ff5cdaf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 22:11:45 +0200
Subject: [PATCH 58/71] refactor(insights): drop the per-page generic medical
disclaimers
Remove the page-level Insights disclaimer footer and the health-score and
correlation card disclaimers now that the not-a-diagnosis acknowledgment
is shown once at onboarding and the legal text stays on the privacy page.
Device-provenance, contraception-safety, statistical-method and
shared-report notes stay where they convey specific information.
---
messages/de.json | 5 ---
messages/en.json | 5 ---
messages/es.json | 5 ---
messages/fr.json | 5 ---
messages/it.json | 5 ---
messages/pl.json | 5 ---
.../__tests__/health-score-card.test.tsx | 35 -------------------
src/components/insights/health-score-card.tsx | 22 ++----------
.../insights/insights-layout-shell.tsx | 25 +++----------
.../insights/metric-correlation-card.tsx | 9 ++---
10 files changed, 11 insertions(+), 110 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index cc51cea47..1b04f3ff0 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -1971,7 +1971,6 @@
"componentWeight": "Gewicht",
"componentMood": "Stimmung",
"componentCompliance": "Therapietreue",
- "disclaimer": "Orientierungshilfe — keine ärztliche Bewertung.",
"deltaExplainer": {
"trigger": "Was bedeutet diese Veränderung?",
"title": "So wird die Veränderung berechnet",
@@ -2052,7 +2051,6 @@
},
"correlationRow": {
"title": "Zusammenhänge",
- "disclaimer": "Zusammenhänge sind beobachtend, nicht kausal — sprich mit deinem Arzt, wenn der Blutdruck hoch ist.",
"experimentCta": "7-Tage-Experiment starten",
"experimentTooltip": "Bald verfügbar",
"emptyTitle": "Mehr Daten nötig, um diesen Zusammenhang zu zeigen",
@@ -3452,9 +3450,6 @@
"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."
- },
"settings": {
"overviewTitle": "Übersicht anordnen",
"overviewDescription": "Abschnitte ein- oder ausblenden und ihre Reihenfolge auf der Insights-Übersicht festlegen."
diff --git a/messages/en.json b/messages/en.json
index 68e3b250a..faf61d5e9 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -1971,7 +1971,6 @@
"componentWeight": "Weight",
"componentMood": "Mood",
"componentCompliance": "Adherence",
- "disclaimer": "Indicative — not a clinical assessment.",
"deltaExplainer": {
"trigger": "What does this delta mean?",
"title": "How the delta is computed",
@@ -2052,7 +2051,6 @@
},
"correlationRow": {
"title": "Relationships",
- "disclaimer": "Relationships are observational, not causal — talk to your doctor if BP runs high.",
"experimentCta": "Try a 7-day experiment",
"experimentTooltip": "Coming soon",
"emptyTitle": "Need more data to see this relationship",
@@ -3452,9 +3450,6 @@
"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."
- },
"settings": {
"overviewTitle": "Arrange the overview",
"overviewDescription": "Show or hide sections and set their order on the Insights overview."
diff --git a/messages/es.json b/messages/es.json
index 3b73054f9..bb248141b 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -1971,7 +1971,6 @@
"componentWeight": "Peso",
"componentMood": "Estado de ánimo",
"componentCompliance": "Cumplimiento",
- "disclaimer": "Orientación — no es valoración médica.",
"deltaExplainer": {
"trigger": "¿Qué significa este cambio?",
"title": "Cómo se calcula el cambio",
@@ -2052,7 +2051,6 @@
},
"correlationRow": {
"title": "Correlaciones",
- "disclaimer": "Las correlaciones son observacionales, no causales — habla con tu médico si tu tensión es alta.",
"experimentCta": "Iniciar experimento de 7 días",
"experimentTooltip": "Próximamente",
"emptyTitle": "Necesitamos más datos para mostrar esta correlación",
@@ -3452,9 +3450,6 @@
"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."
- },
"settings": {
"overviewTitle": "Organizar la vista general",
"overviewDescription": "Muestra u oculta secciones y define su orden en la vista general de Análisis."
diff --git a/messages/fr.json b/messages/fr.json
index 9bec493ee..1f816ae81 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -1971,7 +1971,6 @@
"componentWeight": "Poids",
"componentMood": "Humeur",
"componentCompliance": "Observance",
- "disclaimer": "Repère — pas un avis médical.",
"deltaExplainer": {
"trigger": "Que signifie cet écart ?",
"title": "Comment l'écart est calculé",
@@ -2052,7 +2051,6 @@
},
"correlationRow": {
"title": "Corrélations",
- "disclaimer": "Les corrélations sont observationnelles, pas causales — parle à ton médecin si ta tension est élevée.",
"experimentCta": "Lancer une expérience de 7 jours",
"experimentTooltip": "Bientôt disponible",
"emptyTitle": "Il faut plus de données pour afficher cette corrélation",
@@ -3452,9 +3450,6 @@
"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."
- },
"settings": {
"overviewTitle": "Organiser la vue d'ensemble",
"overviewDescription": "Affichez ou masquez des sections et définissez leur ordre sur la vue d'ensemble des Analyses."
diff --git a/messages/it.json b/messages/it.json
index c05fab90f..d9d0c5c23 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -1971,7 +1971,6 @@
"componentWeight": "Peso",
"componentMood": "Umore",
"componentCompliance": "Aderenza",
- "disclaimer": "Indicazione — non è una valutazione medica.",
"deltaExplainer": {
"trigger": "Cosa significa questa variazione?",
"title": "Come si calcola la variazione",
@@ -2052,7 +2051,6 @@
},
"correlationRow": {
"title": "Correlazioni",
- "disclaimer": "Le correlazioni sono osservazionali, non causali — parla con il medico se la pressione è alta.",
"experimentCta": "Avvia esperimento di 7 giorni",
"experimentTooltip": "In arrivo",
"emptyTitle": "Servono più dati per mostrare questa correlazione",
@@ -3452,9 +3450,6 @@
"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."
- },
"settings": {
"overviewTitle": "Organizza la panoramica",
"overviewDescription": "Mostra o nascondi le sezioni e definisci il loro ordine nella panoramica degli Approfondimenti."
diff --git a/messages/pl.json b/messages/pl.json
index 62f1c1acd..accaf8a3b 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -1971,7 +1971,6 @@
"componentWeight": "Waga",
"componentMood": "Nastrój",
"componentCompliance": "Przestrzeganie",
- "disclaimer": "Wskazówka — nie ocena medyczna.",
"deltaExplainer": {
"trigger": "Co oznacza ta zmiana?",
"title": "Jak liczona jest ta zmiana",
@@ -2052,7 +2051,6 @@
},
"correlationRow": {
"title": "Korelacje",
- "disclaimer": "Korelacje są obserwacyjne, nie przyczynowe — porozmawiaj z lekarzem, jeśli ciśnienie jest wysokie.",
"experimentCta": "Uruchom 7-dniowy eksperyment",
"experimentTooltip": "Wkrótce",
"emptyTitle": "Potrzeba więcej danych, aby pokazać tę korelację",
@@ -3452,9 +3450,6 @@
"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."
- },
"settings": {
"overviewTitle": "Uporządkuj przegląd",
"overviewDescription": "Pokaż lub ukryj sekcje i ustaw ich kolejność w przeglądzie Analiz."
diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx
index ade8459ed..0ed0a1eb0 100644
--- a/src/components/insights/__tests__/health-score-card.test.tsx
+++ b/src/components/insights/__tests__/health-score-card.test.tsx
@@ -192,19 +192,6 @@ describe("", () => {
expect(moodSliceMatch?.[1]).toBe("—");
});
- it("renders the disclaimer", () => {
- const html = ssr(
- ,
- );
- expect(html).toMatch(/data-slot="health-score-card-disclaimer"/);
- expect(html).toContain("Indicative");
- });
-
it("v1.4.27 B1 — no longer mounts an inline Ask-the-Coach button (hero strip carries the CTA)", () => {
// The hero strip's existing "Ask the coach" action covers this
// surface; the card-internal duplicate button retired. The
@@ -245,7 +232,6 @@ describe("", () => {
);
expect(html).toContain("Gesundheitsscore");
expect(html).toContain("im Vergleich zur Vorwoche");
- expect(html).toContain("Orientierungshilfe");
// v1.4.27 B1 — the inline Coach button retired; hero strip carries
// the action now.
expect(html).not.toContain("Coach fragen");
@@ -308,27 +294,6 @@ describe("", () => {
expect(cardOpen?.[0]).toContain("flex-col");
});
- it("pins the disclaimer to the bottom of the stretched card via mt-auto", () => {
- // Without `mt-auto` the disclaimer rides directly under the
- // provenance accordion and leaves the recovered space below the
- // disclaimer instead of between the body and the footer. The
- // FB-H2 contract is "score number on top, disclaimer on bottom,
- // recovered space in the middle".
- const html = ssr(
- ,
- );
- const disclaimerOpen = html.match(
- /
]*data-slot="health-score-card-disclaimer"[^>]*>/,
- );
- expect(disclaimerOpen).not.toBeNull();
- expect(disclaimerOpen?.[0]).toContain("mt-auto");
- });
-
// v1.4.37 W4a item 1 — the inner column switched from `flex
// flex-col gap-3` to a 7-row grid so the slack collects on row 6
// (the provenance accordion) instead of clumping under the
diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx
index 985454608..2782b5871 100644
--- a/src/components/insights/health-score-card.tsx
+++ b/src/components/insights/health-score-card.tsx
@@ -627,25 +627,9 @@ export function HealthScoreCard({
)}
- {/* v1.4.27 B1 — disclaimer bumped from text-[10px] to
- text-[11px] (BL-P4-9 L2: the 10 px size was borderline
- against the 12 px mobile floor). The hero strip already
- carries an "Ask the coach" action so the inline button
- here retires; the parent still receives onAskCoach via
- other surfaces but the card no longer mounts a duplicate
- CTA.
- v1.4.28 R3c-Insights — `mt-auto` pins the disclaimer to
- the bottom of the stretched card so the score number and
- sub-bars stay anchored at the top while the recovered
- vertical space (when the parent column is taller) sits
- quietly between the provenance accordion and the
- disclaimer footer. */}
-
- {t("insights.healthScore.disclaimer")}
-
+ {/* v1.18.6 (DISC-01) — the "indicative, not a clinical assessment"
+ card disclaimer is removed; the one-time onboarding acknowledgment
+ now covers the not-a-diagnosis framing app-wide. */}
);
diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx
index d6b7cacb8..027f9dd02 100644
--- a/src/components/insights/insights-layout-shell.tsx
+++ b/src/components/insights/insights-layout-shell.tsx
@@ -3,7 +3,6 @@
import { useMemo, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
-import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
@@ -52,7 +51,6 @@ interface ComprehensivePayload {
}
export function InsightsLayoutShell({ children }: { children: ReactNode }) {
- const { t } = useTranslations();
const { isAuthenticated, user } = useAuth();
// v1.4.33 F18 — gate the advisor POST on the operator's assistant
// feature flag. Pre-fix, every /insights mount fired POST
@@ -160,24 +158,11 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
modules={user?.modules}
/>
{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.
- v1.18.0 — Coach moved to the standalone `/coach` route (its own
- disclaimer), so this footer now renders under every `/insights/*`
- route unconditionally. */}
-
- {t("insights.disclaimer.footer")}
-
+ {/* v1.18.6 (DISC-01) — the page-level generic medical disclaimer is
+ removed. The "HealthLog is a private tracking tool, not a clinical
+ assessment or diagnosis" acknowledgment now lives once at the start
+ of onboarding, and the legal text stays reachable on the public
+ privacy page. */}
+ {/* v1.18.6 (DISC-01) — the "observational, not causal / talk to your
+ doctor" disclaimer is removed; the one-time onboarding acknowledgment
+ now covers the not-medical-advice framing app-wide. */}
);
}
From 6acc3a72dfa622787a293627d530bada92b6b466 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 22:12:38 +0200
Subject: [PATCH 59/71] docs(api): document the Coach nudge-status and seen
endpoints
Add the GET /api/insights/coach/nudge-status unread signal and the POST
/api/insights/coach/seen open-stamp to the OpenAPI contract and regenerate
the spec.
---
docs/api/openapi.yaml | 99 +++++++++++++++++++++++++++++++++
src/lib/openapi/routes/coach.ts | 60 ++++++++++++++++++++
2 files changed, 159 insertions(+)
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index b632c1c2b..a37716af5 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -4385,6 +4385,47 @@ paths:
"401": *a1
"422": *a3
"429": *a4
+ /api/insights/coach/nudge-status:
+ get:
+ tags:
+ - Insights
+ summary: Whether an unopened Coach message is waiting
+ description: v1.18.6 (CCH-03) — server-authoritative unread signal for the Coach FAB. `unread` is true when the caller's
+ newest Coach ASSISTANT message (a proactive nudge or any reply) is newer than `User.coachLastSeenAt`; a user who
+ has never opened the Coach reads an existing nudge as unread exactly once. `nudgedAt` carries that newest
+ assistant-message timestamp (null when none exists) so the client can key a local seen-mirror on a stable value.
+ Coach-gated (`requireAssistantSurface("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner
+ is narrowed from the session.
+ responses:
+ "200":
+ description: The current unread state.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CoachNudgeStatus"
+ "401": *a1
+ "422": *a3
+ "429": *a4
+ /api/insights/coach/seen:
+ post:
+ tags:
+ - Insights
+ summary: Mark the Coach as opened (clear the unread dot)
+ description: v1.18.6 (CCH-03) — opening the Coach (drawer or full page) stamps `User.coachLastSeenAt = now()`, so `GET
+ /api/insights/coach/nudge-status` then reports no assistant message newer than the stamp and the FAB drops the
+ unread dot. Server-authoritative, so the cleared state follows the caller across web + iOS rather than just the
+ opening device. No request body — the timestamp is server-minted, so a client can never backdate the stamp to
+ suppress a future nudge. Coach-gated; a disabled surface 403s. Auth via cookie or Bearer.
+ responses:
+ "200":
+ description: The Coach was marked opened; the stamp is echoed back.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CoachSeenResponse"
+ "401": *a1
+ "422": *a3
+ "429": *a4
/api/coach/about-me:
get:
tags:
@@ -15777,6 +15818,64 @@ components:
- data
- error
additionalProperties: false
+ CoachNudgeStatus:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ nudgedAt:
+ anyOf:
+ - type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
+ - type: "null"
+ description: Timestamp of the newest Coach assistant message; null when none exists.
+ unread:
+ type: boolean
+ description: True when that message is newer than the last time the caller opened the Coach.
+ required:
+ - nudgedAt
+ - unread
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ CoachSeenResponse:
+ type: object
+ properties:
+ data:
+ type: object
+ properties:
+ seenAt:
+ type: string
+ format: date-time
+ pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
+ description: The server-minted open timestamp.
+ required:
+ - seenAt
+ additionalProperties: false
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
GetCoachAboutMeResponse:
type: object
properties:
diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts
index 94482c51d..1af5d018f 100644
--- a/src/lib/openapi/routes/coach.ts
+++ b/src/lib/openapi/routes/coach.ts
@@ -645,6 +645,66 @@ export const coachPaths: NonNullable = {
},
},
},
+ "/api/insights/coach/nudge-status": {
+ get: {
+ tags: ["Insights"],
+ summary: "Whether an unopened Coach message is waiting",
+ description:
+ "v1.18.6 (CCH-03) — server-authoritative unread signal for the Coach FAB. `unread` is true when the caller's newest Coach ASSISTANT message (a proactive nudge or any reply) is newer than `User.coachLastSeenAt`; a user who has never opened the Coach reads an existing nudge as unread exactly once. `nudgedAt` carries that newest assistant-message timestamp (null when none exists) so the client can key a local seen-mirror on a stable value. Coach-gated (`requireAssistantSurface(\"coach\")`); a disabled surface 403s. Auth via cookie or Bearer; the owner is narrowed from the session.",
+ responses: {
+ "200": {
+ description: "The current unread state.",
+ content: {
+ "application/json": {
+ schema: dataEnvelope(
+ z.object({
+ nudgedAt: z
+ .iso.datetime({ offset: true })
+ .nullable()
+ .describe(
+ "Timestamp of the newest Coach assistant message; null when none exists.",
+ ),
+ unread: z
+ .boolean()
+ .describe(
+ "True when that message is newer than the last time the caller opened the Coach.",
+ ),
+ }),
+ "CoachNudgeStatus",
+ ),
+ },
+ },
+ },
+ ...stdResponses,
+ },
+ },
+ },
+ "/api/insights/coach/seen": {
+ post: {
+ tags: ["Insights"],
+ summary: "Mark the Coach as opened (clear the unread dot)",
+ description:
+ "v1.18.6 (CCH-03) — opening the Coach (drawer or full page) stamps `User.coachLastSeenAt = now()`, so `GET /api/insights/coach/nudge-status` then reports no assistant message newer than the stamp and the FAB drops the unread dot. Server-authoritative, so the cleared state follows the caller across web + iOS rather than just the opening device. No request body — the timestamp is server-minted, so a client can never backdate the stamp to suppress a future nudge. Coach-gated; a disabled surface 403s. Auth via cookie or Bearer.",
+ responses: {
+ "200": {
+ description: "The Coach was marked opened; the stamp is echoed back.",
+ content: {
+ "application/json": {
+ schema: dataEnvelope(
+ z.object({
+ seenAt: z
+ .iso.datetime({ offset: true })
+ .describe("The server-minted open timestamp."),
+ }),
+ "CoachSeenResponse",
+ ),
+ },
+ },
+ },
+ ...stdResponses,
+ },
+ },
+ },
"/api/coach/about-me": {
get: {
tags: ["Insights"],
From 547a7f890447b41e13ca89f38c2a9ebeefa9809b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 22:41:31 +0200
Subject: [PATCH 60/71] fix(a11y): give the due-reminder action a readable
foreground
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Vorsorge "Jetzt messen" / mark-done button hard-coded white text on a
solid green fill. In the dark theme the fill is the bright Dracula green, so
white-on-green reads about 1.37:1 — below the WCAG AA floor.
Add a theme-aware `--success-foreground` token (near-black glyph on the dark
fill, white on the darker light-mode green) mirroring the existing
`--primary-foreground` pattern, and use `text-success-foreground` on both the
card and list variants. Green stays only on the action button; the card
surface remains neutral.
---
src/app/globals.css | 17 +++++++++++++++++
.../measurement-reminders/vorsorge-section.tsx | 14 ++++++++------
2 files changed, 25 insertions(+), 6 deletions(-)
diff --git a/src/app/globals.css b/src/app/globals.css
index 73a7dd6d9..80d94a203 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -96,6 +96,7 @@
* - info → dracula-cyan (neutral informational note)
*/
--color-success: var(--success);
+ --color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-info: var(--info);
@@ -155,6 +156,17 @@
--warning: var(--dracula-orange);
--info: var(--dracula-cyan);
+ /*
+ * Foreground for glyphs/labels painted on a solid `bg-success` fill. The
+ * raw Dracula green (`#50fa7b`) is a bright tone, so white-on-green reads
+ * ~1.37:1 in dark mode — well under AA. The dark theme below pairs it with
+ * the near-black Dracula background (`#282a36`, ~10.4:1); the light theme
+ * keeps white on the darker light-mode green (`#14720a`, ~6.1:1). Mirrors
+ * the `--primary-foreground` pattern. Default here keeps the dark-theme
+ * value so an un-themed root never falls back to an unreadable pairing.
+ */
+ --success-foreground: #282a36;
+
--radius: 0.625rem;
}
@@ -212,6 +224,9 @@
--status-pill-near: #ffb86c;
--status-pill-out: #ff8a8a;
+ /* Dark glyph on the bright Dracula-green fill — ~10.4:1 on `#50fa7b`. */
+ --success-foreground: #282a36;
+
/*
* Dose accent in dark mode — the exact Tailwind `text-purple-400`
* (`oklch(0.714 0.203 305.504)`) the cards used before, so the dark card
@@ -297,6 +312,8 @@
--success: #14720a;
--warning: #a34d14;
--info: #036a96;
+ /* White glyph on the darker light-mode green — ~6.1:1 on `#14720a`. */
+ --success-foreground: #ffffff;
--border: #cfcfde;
--input: #cfcfde;
--ring: #644ac9;
diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx
index 359192b65..fef044321 100644
--- a/src/components/measurement-reminders/vorsorge-section.tsx
+++ b/src/components/measurement-reminders/vorsorge-section.tsx
@@ -753,9 +753,9 @@ function VorsorgeCard({
: "";
const isCoach = reminder.origin === "COACH";
const isLinked = reminder.measurementType != null;
- // Due now / overdue ⇒ the action button takes the green "do it now" tone,
- // analogous to the medication card's due affordance. The CARD stays neutral
- // (no tint) per the project rule — only the action button goes green.
+ // Due now / overdue ⇒ the action button takes the green "do it now" tone.
+ // The CARD stays neutral (no tint) per the project rule — only the action
+ // button goes green; the surface follows the medication-card grammar.
const isDue =
due.key === "nextDue.today" || due.key === "overdueByDays";
const progress = intervalProgress(reminder, now);
@@ -817,13 +817,15 @@ function VorsorgeCard({
// v1.18.6 (MOD-06) — the green "Jetzt messen" / mark-done action. Green only
// on the action button (never the card surface). When not due it stays the
- // calm default tone so the green reads as "now is the time".
+ // calm default tone so the green reads as "now is the time". `bg-success`
+ // pairs with the theme-aware `text-success-foreground` (dark glyph in dark,
+ // white in light) so the label clears WCAG AA in both themes.
const primaryButton = (
Date: Thu, 18 Jun 2026 22:41:38 +0200
Subject: [PATCH 61/71] test(e2e): drive coach launch through the FAB after the
inline icon retired
The per-metric inline coach launch icon was removed from the insights
sub-page header; the floating FAB is now the single entry point. The spec
still asserted the removed icon, so every inline-launch case would fail.
Rewrite the per-slug and drawer-open cases to assert the inline icon is
absent and to open the drawer via the FAB (the pattern the surviving unread
case already used), and update the docstring. Route mocks and the hydration
gate stay as they were.
---
e2e/v1427-coach-launch.spec.ts | 93 ++++++++++++++++++----------------
1 file changed, 48 insertions(+), 45 deletions(-)
diff --git a/e2e/v1427-coach-launch.spec.ts b/e2e/v1427-coach-launch.spec.ts
index 9616b30b0..dd1573888 100644
--- a/e2e/v1427-coach-launch.spec.ts
+++ b/e2e/v1427-coach-launch.spec.ts
@@ -5,17 +5,17 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Coach launch surfaces on `/insights/{slug}` sub-pages.
*
- * Contracts under test (v1.16.8):
+ * Contracts under test (v1.18.6, CCH-04):
*
- * 1. The floating Coach FAB (`data-slot="coach-fab"`) is a permanent
- * launcher on every authenticated page. An unread Coach-initiated
+ * 1. The floating Coach FAB (`data-slot="coach-fab"`) is the single
+ * Coach launcher on every authenticated page. The per-metric inline
+ * launch icon the sub-page header used to mount was retired in
+ * CCH-04 — the FAB is now the only entry. An unread Coach-initiated
* nudge (`GET /api/insights/coach/nudge-status`) paints an unread
* dot (`data-slot="coach-fab-unread"`) on its corner; with nothing
- * unread the FAB renders without the dot. The inline launch the
- * sub-page shell mounts in the header
- * (`data-slot="coach-launch-icon"`) stays the contextual entry.
+ * unread the FAB renders without the dot.
*
- * 2. Clicking the inline launch opens the Coach drawer — as a
+ * 2. Clicking the FAB opens the Coach drawer in place — as a
* bottom-sheet (`data-variant="bottom-sheet"`) on `=sm`.
*
@@ -27,16 +27,16 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
* tray; it hands off to the full-page route `/coach`.
*
* The three sub-pages covered (blutdruck, gewicht, schlaf) span the
- * empty-state branch (no data → CTA + Coach launch) and the populated
- * branch (with data → full chart + Coach launch in the header).
+ * empty-state branch (no data → CTA) and the populated branch (with
+ * data → full chart). The FAB launcher is present on both.
*/
test.describe("Coach launch surfaces on insights sub-pages", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }) => {
// Populated analytics so each sub-page paints the data branch
- // (`hasMetricData` true) — the inline Coach launch is mounted in
- // the sub-page header by the shell.
+ // (`hasMetricData` true). The Coach FAB is the launcher on every
+ // authenticated page regardless of the data branch.
// Regex form matches the slim slice the dashboard split fires
// alongside the thick request.
await page.route(/\/api\/analytics(\?|$)/, (route) =>
@@ -126,21 +126,23 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
});
for (const slug of ["blutdruck", "gewicht", "schlaf"] as const) {
- test(`/insights/${slug} mounts the inline Coach launch and the dot-free FAB on Pixel 5`, async ({
+ test(`/insights/${slug} mounts the dot-free Coach FAB on Pixel 5`, async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-mobile",
- "mobile inline-launch branch",
+ "mobile FAB-launcher branch",
);
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
- // The header launch icon stays the contextual entry point.
- const icon = page.locator('[data-slot="coach-launch-icon"]').first();
- await expect(icon).toBeVisible({ timeout: 10_000 });
+ // CCH-04 — the per-metric inline launch icon is retired; no header
+ // launcher renders on the sub-page.
+ await expect(
+ page.locator('[data-slot="coach-launch-icon"]'),
+ ).toHaveCount(0);
- // The FAB is a permanent launcher; with no unread nudge it
+ // The FAB is the permanent launcher; with no unread nudge it
// renders without the unread dot.
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible({ timeout: 10_000 });
@@ -149,18 +151,19 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
).toHaveCount(0);
});
- test(`/insights/${slug} mounts the inline Coach launch and the dot-free FAB on Desktop Chrome`, async ({
+ test(`/insights/${slug} mounts the dot-free Coach FAB on Desktop Chrome`, async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
- "desktop inline-launch branch",
+ "desktop FAB-launcher branch",
);
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
- const icon = page.locator('[data-slot="coach-launch-icon"]').first();
- await expect(icon).toBeVisible({ timeout: 10_000 });
+ await expect(
+ page.locator('[data-slot="coach-launch-icon"]'),
+ ).toHaveCount(0);
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible({ timeout: 10_000 });
@@ -170,7 +173,7 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
});
}
- test("clicking the inline Coach launch opens the drawer as a bottom-sheet on Pixel 5", async ({
+ test("clicking the Coach FAB opens the drawer as a bottom-sheet on Pixel 5", async ({
page,
}, testInfo) => {
test.skip(
@@ -178,26 +181,26 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
"drawer bottom-sheet branch is mobile-only",
);
- // Hydration gate — the status query is fired by the sub-page
- // boundary itself, so its response proves React has hydrated the
- // page and attached the launch icon's click handler. Clicking the
- // SSR-painted icon any earlier is silently lost (CI flake).
- const pageHydrated = page.waitForResponse(
- /\/api\/insights\/blood-pressure-status/,
+ // Hydration gate — the nudge-status query is fired by the FAB
+ // component itself, so its response proves the FAB's click handler
+ // is attached. Clicking the SSR-painted FAB any earlier is silently
+ // lost (CI flake).
+ const fabHydrated = page.waitForResponse(
+ /\/api\/insights\/coach\/nudge-status/,
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
- await pageHydrated;
+ await fabHydrated;
- const icon = page.locator('[data-slot="coach-launch-icon"]').first();
- await expect(icon).toBeVisible({ timeout: 10_000 });
- await icon.click();
+ const fab = page.locator('[data-slot="coach-fab"]');
+ await expect(fab).toBeVisible({ timeout: 10_000 });
+ await fab.click();
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible({ timeout: 10_000 });
await expect(drawer).toHaveAttribute("data-variant", "bottom-sheet");
});
- test("clicking the inline Coach launch opens the drawer as a side-sheet on Desktop Chrome", async ({
+ test("clicking the Coach FAB opens the drawer as a side-sheet on Desktop Chrome", async ({
page,
}, testInfo) => {
test.skip(
@@ -206,15 +209,15 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
);
// Hydration gate — see the bottom-sheet branch above.
- const pageHydrated = page.waitForResponse(
- /\/api\/insights\/blood-pressure-status/,
+ const fabHydrated = page.waitForResponse(
+ /\/api\/insights\/coach\/nudge-status/,
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
- await pageHydrated;
+ await fabHydrated;
- const icon = page.locator('[data-slot="coach-launch-icon"]').first();
- await expect(icon).toBeVisible({ timeout: 10_000 });
- await icon.click();
+ const fab = page.locator('[data-slot="coach-fab"]');
+ await expect(fab).toBeVisible({ timeout: 10_000 });
+ await fab.click();
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible({ timeout: 10_000 });
@@ -289,15 +292,15 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
);
// Hydration gate — see the bottom-sheet test above.
- const pageHydrated = page.waitForResponse(
- /\/api\/insights\/blood-pressure-status/,
+ const fabHydrated = page.waitForResponse(
+ /\/api\/insights\/coach\/nudge-status/,
);
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
- await pageHydrated;
+ await fabHydrated;
- const icon = page.locator('[data-slot="coach-launch-icon"]').first();
- await expect(icon).toBeVisible({ timeout: 10_000 });
- await icon.click();
+ const fab = page.locator('[data-slot="coach-fab"]');
+ await expect(fab).toBeVisible({ timeout: 10_000 });
+ await fab.click();
const drawer = page.locator('[data-slot="coach-drawer"]');
await expect(drawer).toBeVisible({ timeout: 10_000 });
From ddbb25d5d4bc5d9a08feb6a17b39d1eec9a71462 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 22:41:47 +0200
Subject: [PATCH 62/71] fix(coach): cap proactive nudges by the persisted
conversation, not push success
The proactive-nudge frequency gate only counted delivered push attempts. A
user with no push channel never wrote a delivered row, so the cap was inert
for them: every cron tick that hit a persistent trigger minted a fresh nudge
conversation in the rail.
Count a recently persisted nudge conversation toward the same rolling window
so the once-per-window contract holds regardless of push outcome. Add a
covering index on coach messages for the assistant-message scans the FAB poll
and the gate both run, and drop the unused providerType parameter from the
persistence helper now that the tag is fixed.
---
.../migration.sql | 24 +++++++++++
prisma/schema.prisma | 6 +++
src/lib/ai/coach/persistence.ts | 6 +--
src/lib/jobs/__tests__/coach-nudge.test.ts | 43 +++++++++++++++++++
src/lib/jobs/coach-nudge.ts | 26 +++++++++--
5 files changed, 98 insertions(+), 7 deletions(-)
create mode 100644 prisma/migrations/0177_v1186_coach_message_role_index/migration.sql
diff --git a/prisma/migrations/0177_v1186_coach_message_role_index/migration.sql b/prisma/migrations/0177_v1186_coach_message_role_index/migration.sql
new file mode 100644
index 000000000..1bdaed2d3
--- /dev/null
+++ b/prisma/migrations/0177_v1186_coach_message_role_index/migration.sql
@@ -0,0 +1,24 @@
+-- v1.18.6 (L1) — covering index for the Coach assistant-message scans.
+--
+-- Two hot reads walk a user's assistant messages newest-first:
+-- * the global Coach FAB poll (`GET /api/insights/coach/nudge-status`) —
+-- the newest assistant message across the caller's conversations, used
+-- to decide whether the unread dot paints; and
+-- * the proactive-nudge cron's frequency gate (CCH-02 / M1) — the newest
+-- `provider_type = 'nudge'` assistant message inside the rolling window.
+--
+-- The pre-existing `(conversation_id, created_at)` index does not cover the
+-- `role` / `provider_type` filter, so Postgres gathered every assistant row
+-- in the user's threads and sorted. `coach_messages` grows unbounded, and the
+-- FAB poll fires on every authenticated page with refetch-on-focus.
+--
+-- This index orders by `created_at DESC` after `(conversation_id, role)` so
+-- the planner can do an index-backed per-conversation merge for the
+-- newest-first reads. Additive; no data touched.
+--
+-- Reversibility (down):
+-- DROP INDEX IF EXISTS "coach_messages_conversation_id_role_created_at_idx";
+
+-- CreateIndex
+CREATE INDEX IF NOT EXISTS "coach_messages_conversation_id_role_created_at_idx"
+ ON "coach_messages"("conversation_id", "role", "created_at" DESC);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 76b09170c..c2f620a3c 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -3632,6 +3632,12 @@ model CoachMessage {
feedback RecommendationFeedback[]
@@index([conversationId, createdAt])
+ // v1.18.6 (L1) — the FAB nudge-status poll and the cron's persisted-nudge
+ // frequency gate both scan a user's assistant messages newest-first
+ // (optionally filtered to provider_type = 'nudge'). This index lets the
+ // planner do an index-backed per-conversation merge instead of gathering +
+ // sorting every assistant row in the user's threads on each global poll.
+ @@index([conversationId, role, createdAt(sort: Desc)])
@@map("coach_messages")
}
diff --git a/src/lib/ai/coach/persistence.ts b/src/lib/ai/coach/persistence.ts
index 7e318ee93..a6f9ec7b4 100644
--- a/src/lib/ai/coach/persistence.ts
+++ b/src/lib/ai/coach/persistence.ts
@@ -194,8 +194,6 @@ export interface RecordProactiveNudgeParams {
title: string;
/** The nudge body, persisted as the initial ASSISTANT message. */
body: string;
- /** Trigger tag for the wide-event annotation on the caller side. */
- providerType?: string | null;
}
/**
@@ -228,7 +226,9 @@ export async function recordProactiveNudge(
role: "assistant",
encryptedContent: encryptToBytes(params.body),
metricSourceJson: null,
- providerType: params.providerType ?? "nudge",
+ // Tags the message as a proactive nudge — the cron's frequency gate
+ // reads this back to cap rail conversations for no-push-channel users.
+ providerType: "nudge",
promptVersion: null,
},
});
diff --git a/src/lib/jobs/__tests__/coach-nudge.test.ts b/src/lib/jobs/__tests__/coach-nudge.test.ts
index dfa3a5a23..d69313117 100644
--- a/src/lib/jobs/__tests__/coach-nudge.test.ts
+++ b/src/lib/jobs/__tests__/coach-nudge.test.ts
@@ -524,6 +524,7 @@ describe("runCoachNudgeTick — gates and prefs", () => {
users?: unknown[];
intakeRows?: unknown[];
recentNudge?: unknown;
+ recentPersistedNudge?: unknown;
coachFocusEncrypted?: Uint8Array | null;
}) {
return {
@@ -539,6 +540,12 @@ describe("runCoachNudgeTick — gates and prefs", () => {
return overrides.recentNudge ?? null;
}),
},
+ coachMessage: {
+ findFirst: vi.fn(async (args?: unknown) => {
+ void args;
+ return overrides.recentPersistedNudge ?? null;
+ }),
+ },
medicationIntakeEvent: {
findMany: vi.fn(async () => overrides.intakeRows ?? []),
},
@@ -643,6 +650,42 @@ describe("runCoachNudgeTick — gates and prefs", () => {
);
});
+ it("caps on a recently persisted nudge conversation even with no push-success row (M1)", async () => {
+ // A no-push-channel user never writes an `ok` push-attempt row, so the
+ // ledger side of gate 5 stays empty. The persisted nudge conversation
+ // from a prior tick must still pin the window so the rail does not get a
+ // brand-new nudge conversation every cron run.
+ const dispatch = vi.fn();
+ const recordNudge = vi.fn();
+ const prisma = prismaMock({
+ users: [userRow(null)],
+ intakeRows: failingIntakes,
+ recentNudge: null,
+ recentPersistedNudge: { id: "msg_recent" },
+ });
+ const summary = await runCoachNudgeTick(
+ prisma as unknown as PrismaClient,
+ now,
+ { dispatch: dispatch as never, recordNudge: recordNudge as never },
+ );
+ expect(summary.skippedRecentNudge).toBe(1);
+ expect(dispatch).not.toHaveBeenCalled();
+ expect(recordNudge).not.toHaveBeenCalled();
+ // The persisted-nudge query honours the same rolling cutoff as the ledger.
+ const arg = vi.mocked(prisma.coachMessage.findFirst).mock.calls[0]?.[0] as {
+ where: {
+ providerType: string;
+ role: string;
+ createdAt: { gte: Date };
+ };
+ } | undefined;
+ expect(arg?.where.providerType).toBe("nudge");
+ expect(arg?.where.role).toBe("assistant");
+ expect(arg?.where.createdAt.gte.getTime()).toBe(
+ now.getTime() - 7 * MS_PER_DAY,
+ );
+ });
+
it("skips a disabled group's trigger queries entirely", async () => {
const prisma = prismaMock({
users: [
diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts
index d1fe2724e..24c2d948f 100644
--- a/src/lib/jobs/coach-nudge.ts
+++ b/src/lib/jobs/coach-nudge.ts
@@ -782,10 +782,15 @@ export async function runCoachNudgeTick(
}
// Gate 5 — one nudge per rolling window (7 d default, 14 d when
- // the user picked "biweekly"), anchored on the push-attempts
- // ledger. A delivered nudge writes an `ok` row per succeeding
- // channel; a fully failed dispatch leaves the slot free so
- // tomorrow's tick can retry.
+ // the user picked "biweekly"). Anchored on BOTH the push-attempts
+ // ledger (a delivered nudge writes an `ok` row per succeeding
+ // channel) AND the persisted nudge conversation (v1.18.6 CCH-02
+ // writes one regardless of push outcome). A user with no push
+ // channel never gets an `ok` row, so without the persisted-side
+ // check the cap would be inert for them and every tick would mint a
+ // fresh rail conversation. A fully failed dispatch on a push-capable
+ // user still leaves the ledger slot free, but the persisted nudge
+ // already pins the window — exactly the once-per-window contract.
const capCutoff = new Date(
now.getTime() - nudgePrefs.minIntervalDays * MS_PER_DAY,
);
@@ -802,6 +807,19 @@ export async function runCoachNudgeTick(
summary.skippedRecentNudge += 1;
continue;
}
+ const recentPersistedNudge = await prisma.coachMessage.findFirst({
+ where: {
+ providerType: "nudge",
+ role: "assistant",
+ conversation: { userId: user.id },
+ createdAt: { gte: capCutoff },
+ },
+ select: { id: true },
+ });
+ if (recentPersistedNudge) {
+ summary.skippedRecentNudge += 1;
+ continue;
+ }
// Gate 6 — Rest Mode pause (v1.18.1 P4). A user with an active
// illness/condition episode should not be told to measure more often;
From 397423dddc261c5876e79889db581ff6443243dd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 22:41:57 +0200
Subject: [PATCH 63/71] docs(api): register the onboarding disclaimer endpoint
in the contract
The one-time disclaimer acknowledgment route shipped without an OpenAPI
entry, leaving a gap in the contract the iOS client consumes. Add the POST
path and regenerate the spec.
---
docs/api/openapi.yaml | 63 ++++++++++++++++++++++++++++
src/lib/openapi/routes/onboarding.ts | 52 +++++++++++++++++++++++
2 files changed, 115 insertions(+)
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index a37716af5..166748f5d 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -6861,6 +6861,30 @@ paths:
"401": *a1
"422": *a3
"429": *a4
+ /api/onboarding/disclaimer:
+ post:
+ tags:
+ - Onboarding
+ summary: Acknowledge the one-time medical disclaimer
+ description: "Stamps the user's medical-disclaimer acknowledgment. Idempotent: a repeat acknowledgment of the same
+ version refreshes the timestamp. The body version is a freshness signal so a stale shell cannot record copy it
+ never rendered; the server persists its own canonical version."
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DisclaimerAckRequest"
+ responses:
+ "200":
+ description: Disclaimer acknowledged.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DisclaimerAckEnvelope"
+ "401": *a1
+ "422": *a3
+ "429": *a4
/api/onboarding/tour:
post:
tags:
@@ -8225,6 +8249,18 @@ components:
type: string
required:
- ids
+ DisclaimerAckRequest:
+ type: object
+ properties:
+ version:
+ type: string
+ minLength: 1
+ maxLength: 64
+ description: The disclaimer copy version the client rendered. A freshness signal only — the server pins and persists its
+ own canonical version.
+ required:
+ - version
+ description: Acknowledge the one-time medical disclaimer shown at onboarding.
TourUpdateRequest:
type: object
properties:
@@ -19681,6 +19717,33 @@ components:
description: "Cross-episode retrospective summary over the trailing window: episode + resolved counts, the typical
(median) recovery gap (null below the min-sample floor — withholds a thin claim), a recurrence-by-month tally,
and a per-type breakdown. Retrospective only."
+ DisclaimerAckEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/DisclaimerAckResponse"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ DisclaimerAckResponse:
+ type: object
+ properties:
+ acknowledgedVersion:
+ type: string
+ description: The canonical disclaimer version the server stamped.
+ required:
+ - acknowledgedVersion
+ additionalProperties: false
+ description: The persisted disclaimer acknowledgment version.
TourUpdateEnvelope:
type: object
properties:
diff --git a/src/lib/openapi/routes/onboarding.ts b/src/lib/openapi/routes/onboarding.ts
index efdc1b4cf..7f57fb4dd 100644
--- a/src/lib/openapi/routes/onboarding.ts
+++ b/src/lib/openapi/routes/onboarding.ts
@@ -54,7 +54,59 @@ const tourUpdateResponse = z
description: "The persisted completion flag and resume point after the write.",
});
+const disclaimerAckRequest = z
+ .object({
+ version: z
+ .string()
+ .min(1)
+ .max(64)
+ .describe(
+ "The disclaimer copy version the client rendered. A freshness signal only — the server pins and persists its own canonical version.",
+ ),
+ })
+ .meta({
+ id: "DisclaimerAckRequest",
+ description: "Acknowledge the one-time medical disclaimer shown at onboarding.",
+ });
+
+const disclaimerAckResponse = z
+ .object({
+ acknowledgedVersion: z
+ .string()
+ .describe("The canonical disclaimer version the server stamped."),
+ })
+ .meta({
+ id: "DisclaimerAckResponse",
+ description: "The persisted disclaimer acknowledgment version.",
+ });
+
export const onboardingPaths: NonNullable = {
+ "/api/onboarding/disclaimer": {
+ post: {
+ tags: ["Onboarding"],
+ summary: "Acknowledge the one-time medical disclaimer",
+ description:
+ "Stamps the user's medical-disclaimer acknowledgment. Idempotent: a repeat acknowledgment of the same version refreshes the timestamp. The body version is a freshness signal so a stale shell cannot record copy it never rendered; the server persists its own canonical version.",
+ requestBody: {
+ required: true,
+ content: { "application/json": { schema: disclaimerAckRequest } },
+ },
+ responses: {
+ "200": {
+ description: "Disclaimer acknowledged.",
+ content: {
+ "application/json": {
+ schema: dataEnvelope(
+ disclaimerAckResponse,
+ "DisclaimerAckEnvelope",
+ ),
+ },
+ },
+ },
+ ...stdResponses,
+ },
+ },
+ },
"/api/onboarding/tour": {
post: {
tags: ["Onboarding"],
From 9014203d8a9d8e0dadaf231e415248ff9b1348a3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 22:42:04 +0200
Subject: [PATCH 64/71] fix(a11y): label per-reminder toggles and wire
disclosure controls
The diabetes PATCH parsed its body without a size cap; bound it through
safeJson like the sibling routes. Label each reminder's enable/disable switch
by its own name span so screen readers hear the reminder rather than one
generic label repeated per row. Point the chronotype and illness disclosure
triggers at the regions they reveal via aria-controls.
---
src/app/api/auth/me/diabetes/route.ts | 15 ++++++++-------
src/components/illness/illness-view.tsx | 5 ++++-
src/components/insights/sleep/chronotype-card.tsx | 3 ++-
.../measurement-reminders/vorsorge-settings.tsx | 10 ++++++++--
4 files changed, 22 insertions(+), 11 deletions(-)
diff --git a/src/app/api/auth/me/diabetes/route.ts b/src/app/api/auth/me/diabetes/route.ts
index 56d72da64..2b3e48e15 100644
--- a/src/app/api/auth/me/diabetes/route.ts
+++ b/src/app/api/auth/me/diabetes/route.ts
@@ -19,12 +19,13 @@
*/
import { z } from "zod";
-import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
+import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
+ safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -74,12 +75,12 @@ export const PATCH = apiHandler(async (req: Request) => {
return response;
}
- let body: unknown;
- try {
- body = await req.json();
- } catch {
- throw new HttpError(422, "diabetes.body.invalid_json");
- }
+ // The body is a single boolean — bound the parse so a malformed or
+ // oversized payload is rejected before it is materialised.
+ const { data: body, error: jsonError } = await safeJson(req, {
+ maxBytes: 1024,
+ });
+ if (jsonError) return jsonError;
const parsed = patchBodySchema.safeParse(body);
if (!parsed.success) {
diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx
index 10101938c..7af11279d 100644
--- a/src/components/illness/illness-view.tsx
+++ b/src/components/illness/illness-view.tsx
@@ -15,7 +15,7 @@
* per-day timeline and correlation card live. Retrospective-only copy — a
* journal, not a medical device, and it does not diagnose.
*/
-import { useMemo, useState } from "react";
+import { useId, useMemo, useState } from "react";
import Link from "next/link";
import { ChevronDown, Plus, Stethoscope, Wrench } from "lucide-react";
@@ -245,10 +245,12 @@ function EpisodeGroup({
}) {
// Hooks must run unconditionally; bail on the empty render afterwards.
const [open, setOpen] = useState(false);
+ const gridId = useId();
if (parents.length === 0) return null;
const grid = (
))}
From 9eed8d9cfee222fdeb5644f5711fe3109362c2c5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 22:42:10 +0200
Subject: [PATCH 65/71] chore(i18n): drop orphaned message keys across the six
locales
Remove three diabetes settings strings whose web toggle was never built, four
dead reference-state explainer keys with no consumer, and the generic
reminder-toggle aria label that the per-row aria-labelledby wiring replaced.
The backend and resolver behind the diabetes preference stay in place.
---
messages/de.json | 14 ++------------
messages/en.json | 14 ++------------
messages/es.json | 14 ++------------
messages/fr.json | 14 ++------------
messages/it.json | 14 ++------------
messages/pl.json | 14 ++------------
6 files changed, 12 insertions(+), 72 deletions(-)
diff --git a/messages/de.json b/messages/de.json
index 1b04f3ff0..321cf3dc0 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -2498,13 +2498,7 @@
"meanArterialPressureBody": "Der mittlere arterielle Druck ist der durchschnittliche Druck, den deine Organe über einen Herzschlag sehen – grob der diastolische Wert plus ein Drittel der Differenz zum systolischen. Als allgemeine Orientierung liegt ein Ruhewert bei etwa 70–100 mmHg (StatPearls/NIH 2023). Die oft genannte Grenze von ~60 mmHg ist ein Konzept der Akutmedizin, kein Alarm für die Selbstmessung.",
"cycleEducationBody": "Dein Zyklus durchläuft vier hormongesteuerte Phasen, und seine Länge und Regelmäßigkeit sind ein gut zugängliches Fenster auf die allgemeine Gesundheit. Als allgemeine Orientierung dauern Zyklen bei Erwachsenen typischerweise 21–35 Tage (klinische Referenzen nennen 24–38), mit einer Blutung von rund fünf Tagen (ACOG 2015). Die Lutealphase ist der stabile Anker, daher schätzt man den Eisprung am besten durch Rückrechnen von der nächsten erwarteten Periode – nie über einen festen „Tag 14“. Ein vorhergesagtes fruchtbares Fenster ist eine Schätzung, die mehrere erfasste Zyklen braucht und ungefähr bleibt, und die Basaltemperatur bestätigt den Eisprung im Nachhinein, statt ihn vorherzusagen. Dies ist keine Verhütungsmethode.",
"sleepHygieneBody": "Einige evidenzbasierte Gewohnheiten unterstützen den Schlaf: gleichbleibende Zubett- und Aufstehzeiten auch am Wochenende, helles Morgenlicht und gedämpfte Abende, ein kühles, dunkles und ruhiges Zimmer sowie der Verzicht auf Koffein am Nachmittag, späten Alkohol und große späte Mahlzeiten (CDC 2024). Regelmäßigkeit sagt die Gesundheit mindestens so stark voraus wie die Dauer, und die Gesamtschlafzeit eines Wearables ist weit verlässlicher als seine Aufteilung in Tief-, Leicht- und REM-Schlaf.",
- "referenceGuidance": "Allgemeine Orientierung, keine medizinische Beratung. Referenzbereiche sind Populationswerte, die sich mit Alter, Geschlecht und Erkrankungen verschieben – dein eigener Verlauf über die Zeit führt die Einordnung.",
- "referenceState": {
- "within": "Dein letzter Wert liegt im allgemeinen Referenzbereich.",
- "slightlyOutside": "Dein letzter Wert liegt knapp außerhalb des üblichen Bereichs – behalte den Verlauf im Blick.",
- "outside": "Dein letzter Wert liegt außerhalb des üblichen Bereichs. Das ist eine Einordnungshilfe, keine Diagnose; besprich ein anhaltendes Muster mit einer Ärztin oder einem Arzt.",
- "insufficient": "Es gibt noch nicht genug Daten, oder dieses Signal hat keinen festen Populationsbereich – dein eigener Ausgangswert führt die Einordnung."
- }
+ "referenceGuidance": "Allgemeine Orientierung, keine medizinische Beratung. Referenzbereiche sind Populationswerte, die sich mit Alter, Geschlecht und Erkrankungen verschieben – dein eigener Verlauf über die Zeit führt die Einordnung."
},
"bpDerived": {
"pulsePressure": "Der Pulsdruck (die Differenz zwischen deinen Werten) beträgt etwa {value} mmHg – rund 40 ist in Ruhe typisch.",
@@ -4768,10 +4762,7 @@
"savedOff": "Coach ist ausgeblendet.",
"saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen."
}
- },
- "diabetes": "Ich habe Diabetes",
- "diabetesDescription": "Nutze ärztliche Glukose-Zielwerte (nüchtern 80–130, nach dem Essen unter 180 mg/dL) statt der allgemeinen Bänder. Das ändert nur den Zielbereich, an dem du gemessen wirst — es ist keine Diagnose.",
- "diabetesSaved": "Glukose-Zielwert-Einstellung gespeichert"
+ }
},
"admin": {
"title": "Administration",
@@ -6405,7 +6396,6 @@
"pushLocation": "(bei {location})",
"edit": "Bearbeiten",
"originCoach": "Coach",
- "enabledToggleAria": "Diese Erinnerung aktivieren oder deaktivieren",
"until": "Bis {date}",
"selfPlanned": "Selbstgeplant",
"captureValue": "Wert erfassen",
diff --git a/messages/en.json b/messages/en.json
index faf61d5e9..2dd3fb4f3 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -2498,13 +2498,7 @@
"meanArterialPressureBody": "Mean arterial pressure is the average pressure your organs see across a heartbeat, roughly the diastolic plus a third of the gap to systolic. As general guidance, a resting value sits around 70–100 mmHg (StatPearls/NIH 2023). The often-cited ~60 mmHg floor is an acute-care concept, not a consumer self-alarm.",
"cycleEducationBody": "Your cycle runs through four phases driven by shifting hormones, and its length and regularity are an accessible window on overall health. As general guidance, adult cycles typically run 21–35 days (clinical references cite 24–38), with bleeding around five days (ACOG 2015). The luteal phase is the steady anchor, so ovulation is best estimated by counting back from your next expected period — never a fixed 'day 14'. A predicted fertile window is an estimate that needs several recorded cycles and stays approximate, and basal body temperature confirms ovulation after the fact rather than predicting it. This is not a contraceptive method.",
"sleepHygieneBody": "A few evidence-based habits support sleep: keep consistent bed and wake times including weekends, get bright morning daylight and dim your evenings, keep the room cool, dark and quiet, and avoid afternoon caffeine, late alcohol and large late meals (CDC 2024). Regularity predicts health at least as strongly as duration, and a wearable's total sleep time is far more trustworthy than its deep, light and REM split.",
- "referenceGuidance": "General guidance, not medical advice. Reference ranges are population anchors that shift with your age, sex and health conditions — your own trend over time leads the read.",
- "referenceState": {
- "within": "Your recent value sits within the general reference range.",
- "slightlyOutside": "Your recent value sits just outside the usual range — worth keeping an eye on the trend.",
- "outside": "Your recent value sits outside the usual range. This is a placement aid, not a diagnosis; discuss a sustained pattern with a clinician.",
- "insufficient": "There isn't enough data yet, or this signal has no fixed population range — your own baseline carries the read."
- }
+ "referenceGuidance": "General guidance, not medical advice. Reference ranges are population anchors that shift with your age, sex and health conditions — your own trend over time leads the read."
},
"bpDerived": {
"pulsePressure": "Pulse pressure (the gap between your readings) is about {value} mmHg — roughly 40 is typical at rest.",
@@ -4768,10 +4762,7 @@
"savedOff": "Coach hidden.",
"saveError": "Could not save the change. Please try again."
}
- },
- "diabetes": "I have diabetes",
- "diabetesDescription": "Use clinician glucose targets (fasting 80–130, post-meal under 180 mg/dL) instead of the general bands. This only changes the target you are measured against — it is never a diagnosis.",
- "diabetesSaved": "Glucose target preference saved"
+ }
},
"admin": {
"title": "Administration",
@@ -6405,7 +6396,6 @@
"pushLocation": "(at {location})",
"edit": "Edit",
"originCoach": "Coach",
- "enabledToggleAria": "Enable or disable this reminder",
"until": "Until {date}",
"selfPlanned": "Self-planned",
"captureValue": "Log value",
diff --git a/messages/es.json b/messages/es.json
index bb248141b..e5a4a8a3d 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -2498,13 +2498,7 @@
"meanArterialPressureBody": "La presión arterial media es la presión promedio que ven tus órganos a lo largo de un latido, aproximadamente la diastólica más un tercio de la diferencia con la sistólica. Como orientación general, un valor en reposo ronda los 70–100 mmHg (StatPearls/NIH 2023). El umbral de ~60 mmHg que se suele citar es un concepto de cuidados agudos, no una alarma para el autocontrol.",
"cycleEducationBody": "Tu ciclo atraviesa cuatro fases impulsadas por hormonas, y su duración y regularidad son una ventana accesible a la salud general. Como orientación general, los ciclos en personas adultas suelen durar 21–35 días (las referencias clínicas citan 24–38), con un sangrado de unos cinco días (ACOG 2015). La fase lútea es el ancla estable, así que la ovulación se estima mejor contando hacia atrás desde tu próxima regla prevista, nunca con un «día 14» fijo. Una ventana fértil predicha es una estimación que necesita varios ciclos registrados y sigue siendo aproximada, y la temperatura basal confirma la ovulación a posteriori en lugar de predecirla. No es un método anticonceptivo.",
"sleepHygieneBody": "Algunos hábitos con respaldo de evidencia favorecen el sueño: mantener horarios constantes de acostarse y levantarse incluso los fines de semana, recibir luz brillante por la mañana y atenuar las noches, una habitación fresca, oscura y silenciosa, y evitar la cafeína por la tarde, el alcohol tardío y las comidas copiosas a última hora (CDC 2024). La regularidad predice la salud al menos tanto como la duración, y el tiempo total de sueño de un wearable es mucho más fiable que su reparto entre sueño profundo, ligero y REM.",
- "referenceGuidance": "Orientación general, no consejo médico. Los rangos de referencia son valores poblacionales que cambian con tu edad, sexo y condiciones de salud; tu propia tendencia a lo largo del tiempo guía la lectura.",
- "referenceState": {
- "within": "Tu valor reciente se sitúa dentro del rango de referencia general.",
- "slightlyOutside": "Tu valor reciente queda justo fuera del rango habitual; conviene vigilar la tendencia.",
- "outside": "Tu valor reciente queda fuera del rango habitual. Es una ayuda de ubicación, no un diagnóstico; comenta un patrón sostenido con un profesional sanitario.",
- "insufficient": "Aún no hay datos suficientes, o esta señal no tiene un rango poblacional fijo; tu propia base guía la lectura."
- }
+ "referenceGuidance": "Orientación general, no consejo médico. Los rangos de referencia son valores poblacionales que cambian con tu edad, sexo y condiciones de salud; tu propia tendencia a lo largo del tiempo guía la lectura."
},
"bpDerived": {
"pulsePressure": "La presión de pulso (la diferencia entre tus valores) es de unos {value} mmHg; alrededor de 40 es lo típico en reposo.",
@@ -4768,10 +4762,7 @@
"savedOff": "Coach oculto.",
"saveError": "No se pudo guardar el cambio. Inténtalo de nuevo."
}
- },
- "diabetes": "Tengo diabetes",
- "diabetesDescription": "Usa objetivos de glucosa clínicos (en ayunas 80–130, tras la comida menos de 180 mg/dL) en lugar de las bandas generales. Esto solo cambia el objetivo con el que te comparas; nunca es un diagnóstico.",
- "diabetesSaved": "Preferencia de objetivo de glucosa guardada"
+ }
},
"admin": {
"title": "Administration",
@@ -6405,7 +6396,6 @@
"pushLocation": "(en {location})",
"edit": "Editar",
"originCoach": "Coach",
- "enabledToggleAria": "Activar o desactivar este recordatorio",
"until": "Hasta {date}",
"selfPlanned": "Autoplanificado",
"captureValue": "Registrar valor",
diff --git a/messages/fr.json b/messages/fr.json
index 1f816ae81..a9b43fc82 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -2498,13 +2498,7 @@
"meanArterialPressureBody": "La pression artérielle moyenne est la pression moyenne que voient vos organes sur un battement, soit grosso modo la diastolique plus un tiers de l'écart avec la systolique. À titre indicatif général, une valeur au repos se situe autour de 70–100 mmHg (StatPearls/NIH 2023). Le seuil de ~60 mmHg souvent cité est un concept de soins aigus, pas une alarme pour l'autosurveillance.",
"cycleEducationBody": "Votre cycle traverse quatre phases pilotées par les hormones, et sa durée et sa régularité sont une fenêtre accessible sur la santé globale. À titre indicatif général, les cycles chez l'adulte durent typiquement 21–35 jours (les références cliniques citent 24–38), avec des règles d'environ cinq jours (ACOG 2015). La phase lutéale est l'ancrage stable, donc l'ovulation s'estime au mieux en comptant à rebours depuis vos prochaines règles prévues, jamais à un « jour 14 » fixe. Une fenêtre fertile prédite est une estimation qui demande plusieurs cycles enregistrés et reste approximative, et la température basale confirme l'ovulation après coup plutôt qu'elle ne la prédit. Ce n'est pas une méthode de contraception.",
"sleepHygieneBody": "Quelques habitudes fondées sur les preuves favorisent le sommeil : des heures de coucher et de lever régulières, week-ends compris, une lumière vive le matin et des soirées tamisées, une chambre fraîche, sombre et calme, et éviter la caféine l'après-midi, l'alcool tardif et les repas copieux tard le soir (CDC 2024). La régularité prédit la santé au moins autant que la durée, et le temps de sommeil total d'un wearable est bien plus fiable que sa répartition entre sommeil profond, léger et paradoxal.",
- "referenceGuidance": "Indication générale, pas un avis médical. Les plages de référence sont des repères de population qui évoluent avec votre âge, votre sexe et vos conditions de santé ; votre propre tendance dans le temps guide la lecture.",
- "referenceState": {
- "within": "Votre valeur récente se situe dans la plage de référence générale.",
- "slightlyOutside": "Votre valeur récente est juste en dehors de la plage habituelle — il vaut la peine de surveiller la tendance.",
- "outside": "Votre valeur récente est en dehors de la plage habituelle. C'est une aide au repérage, pas un diagnostic ; parlez d'un schéma persistant à un professionnel de santé.",
- "insufficient": "Il n'y a pas encore assez de données, ou ce signal n'a pas de plage de population fixe — votre propre référence guide la lecture."
- }
+ "referenceGuidance": "Indication générale, pas un avis médical. Les plages de référence sont des repères de population qui évoluent avec votre âge, votre sexe et vos conditions de santé ; votre propre tendance dans le temps guide la lecture."
},
"bpDerived": {
"pulsePressure": "La pression pulsée (l'écart entre vos valeurs) est d'environ {value} mmHg ; environ 40 est typique au repos.",
@@ -4768,10 +4762,7 @@
"savedOff": "Coach masqué.",
"saveError": "Impossible d enregistrer la modification. Veuillez réessayer."
}
- },
- "diabetes": "Je suis diabétique",
- "diabetesDescription": "Utilise des objectifs glycémiques cliniques (à jeun 80–130, après le repas moins de 180 mg/dL) au lieu des plages générales. Cela ne change que l'objectif auquel tu es comparé — ce n'est jamais un diagnostic.",
- "diabetesSaved": "Préférence d'objectif glycémique enregistrée"
+ }
},
"admin": {
"title": "Administration",
@@ -6405,7 +6396,6 @@
"pushLocation": "(chez {location})",
"edit": "Modifier",
"originCoach": "Coach",
- "enabledToggleAria": "Activer ou désactiver ce rappel",
"until": "Jusqu'au {date}",
"selfPlanned": "Auto-planifié",
"captureValue": "Enregistrer la valeur",
diff --git a/messages/it.json b/messages/it.json
index d9d0c5c23..672aaa46a 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -2498,13 +2498,7 @@
"meanArterialPressureBody": "La pressione arteriosa media è la pressione media che i tuoi organi vedono in un battito, all'incirca la diastolica più un terzo del divario con la sistolica. Come orientamento generale, un valore a riposo si aggira sui 70–100 mmHg (StatPearls/NIH 2023). La soglia di ~60 mmHg spesso citata è un concetto di terapia intensiva, non un allarme per l'automisurazione.",
"cycleEducationBody": "Il tuo ciclo attraversa quattro fasi guidate dagli ormoni, e la sua durata e regolarità sono una finestra accessibile sulla salute generale. Come orientamento generale, nei adulti i cicli durano tipicamente 21–35 giorni (i riferimenti clinici citano 24–38), con un sanguinamento di circa cinque giorni (ACOG 2015). La fase luteale è l'ancora stabile, quindi l'ovulazione si stima meglio contando a ritroso dalla prossima mestruazione attesa, mai con un «giorno 14» fisso. Una finestra fertile prevista è una stima che richiede più cicli registrati e resta approssimativa, e la temperatura basale conferma l'ovulazione a posteriori anziché prevederla. Non è un metodo contraccettivo.",
"sleepHygieneBody": "Alcune abitudini basate sull'evidenza favoriscono il sonno: orari costanti per coricarsi e svegliarsi anche nel weekend, luce intensa al mattino e serate soffuse, una stanza fresca, buia e silenziosa, ed evitare la caffeina nel pomeriggio, l'alcol a tarda ora e i pasti abbondanti a fine giornata (CDC 2024). La regolarità predice la salute almeno quanto la durata, e il tempo di sonno totale di un wearable è molto più affidabile della sua suddivisione tra sonno profondo, leggero e REM.",
- "referenceGuidance": "Orientamento generale, non un consiglio medico. Gli intervalli di riferimento sono valori di popolazione che cambiano con la tua età, il sesso e le condizioni di salute; il tuo andamento nel tempo guida la lettura.",
- "referenceState": {
- "within": "Il tuo valore recente rientra nell'intervallo di riferimento generale.",
- "slightlyOutside": "Il tuo valore recente è appena fuori dall'intervallo abituale: vale la pena tenere d'occhio l'andamento.",
- "outside": "Il tuo valore recente è fuori dall'intervallo abituale. È un aiuto alla collocazione, non una diagnosi; parla di un andamento persistente con un medico.",
- "insufficient": "Non ci sono ancora dati sufficienti, oppure questo segnale non ha un intervallo di popolazione fisso: il tuo riferimento personale guida la lettura."
- }
+ "referenceGuidance": "Orientamento generale, non un consiglio medico. Gli intervalli di riferimento sono valori di popolazione che cambiano con la tua età, il sesso e le condizioni di salute; il tuo andamento nel tempo guida la lettura."
},
"bpDerived": {
"pulsePressure": "La pressione differenziale (il divario tra i tuoi valori) è di circa {value} mmHg; circa 40 è tipico a riposo.",
@@ -4768,10 +4762,7 @@
"savedOff": "Coach nascosto.",
"saveError": "Impossibile salvare la modifica. Riprova."
}
- },
- "diabetes": "Ho il diabete",
- "diabetesDescription": "Usa obiettivi glicemici clinici (a digiuno 80–130, dopo i pasti sotto 180 mg/dL) invece delle bande generali. Questo cambia solo l'obiettivo con cui ti confronti — non è mai una diagnosi.",
- "diabetesSaved": "Preferenza dell'obiettivo glicemico salvata"
+ }
},
"admin": {
"title": "Administration",
@@ -6405,7 +6396,6 @@
"pushLocation": "(presso {location})",
"edit": "Modifica",
"originCoach": "Coach",
- "enabledToggleAria": "Attiva o disattiva questo promemoria",
"until": "Fino al {date}",
"selfPlanned": "Autopianificato",
"captureValue": "Registra valore",
diff --git a/messages/pl.json b/messages/pl.json
index accaf8a3b..b4ccb8202 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -2498,13 +2498,7 @@
"meanArterialPressureBody": "Średnie ciśnienie tętnicze to przeciętne ciśnienie, jakie widzą Twoje narządy w trakcie uderzenia serca, w przybliżeniu rozkurczowe plus jedna trzecia różnicy do skurczowego. Jako ogólna wskazówka, wartość spoczynkowa mieści się w okolicy 70–100 mmHg (StatPearls/NIH 2023). Często przywoływany próg ~60 mmHg to pojęcie z medycyny ostrej, nie alarm do samodzielnego pomiaru.",
"cycleEducationBody": "Twój cykl przebiega przez cztery fazy sterowane hormonami, a jego długość i regularność to dostępne okno na ogólny stan zdrowia. Jako ogólna wskazówka, u osób dorosłych cykle trwają zwykle 21–35 dni (źródła kliniczne podają 24–38), z krwawieniem około pięciu dni (ACOG 2015). Faza lutealna jest stałą kotwicą, dlatego owulację najlepiej szacować, licząc wstecz od następnej spodziewanej miesiączki, nigdy według stałego „14. dnia”. Przewidziane okno płodne to oszacowanie, które wymaga kilku zarejestrowanych cykli i pozostaje przybliżone, a podstawowa temperatura ciała potwierdza owulację po fakcie, a nie ją przewiduje. To nie jest metoda antykoncepcji.",
"sleepHygieneBody": "Kilka opartych na dowodach nawyków wspiera sen: stałe pory kładzenia się i wstawania także w weekendy, jasne światło rano i przyciemnione wieczory, chłodna, ciemna i cicha sypialnia oraz unikanie kofeiny po południu, alkoholu późnym wieczorem i obfitych posiłków przed snem (CDC 2024). Regularność przewiduje zdrowie co najmniej tak silnie jak długość snu, a całkowity czas snu z urządzenia jest znacznie bardziej wiarygodny niż jego podział na sen głęboki, lekki i REM.",
- "referenceGuidance": "Ogólna wskazówka, nie porada medyczna. Zakresy referencyjne to wartości populacyjne, które zmieniają się wraz z Twoim wiekiem, płcią i stanem zdrowia; to Twój własny trend w czasie prowadzi odczyt.",
- "referenceState": {
- "within": "Twoja ostatnia wartość mieści się w ogólnym zakresie referencyjnym.",
- "slightlyOutside": "Twoja ostatnia wartość jest tuż poza zwykłym zakresem – warto obserwować trend.",
- "outside": "Twoja ostatnia wartość jest poza zwykłym zakresem. To pomoc w umiejscowieniu, nie diagnoza; omów utrzymujący się wzorzec z lekarzem.",
- "insufficient": "Nie ma jeszcze wystarczających danych lub ten sygnał nie ma stałego zakresu populacyjnego – odczyt prowadzi Twoja własna wartość bazowa."
- }
+ "referenceGuidance": "Ogólna wskazówka, nie porada medyczna. Zakresy referencyjne to wartości populacyjne, które zmieniają się wraz z Twoim wiekiem, płcią i stanem zdrowia; to Twój własny trend w czasie prowadzi odczyt."
},
"bpDerived": {
"pulsePressure": "Ciśnienie tętna (różnica między Twoimi wartościami) wynosi około {value} mmHg – około 40 jest typowe w spoczynku.",
@@ -4768,10 +4762,7 @@
"savedOff": "Coach ukryty.",
"saveError": "Nie udało się zapisać zmiany. Spróbuj ponownie."
}
- },
- "diabetes": "Mam cukrzycę",
- "diabetesDescription": "Użyj klinicznych celów glukozy (na czczo 80–130, po posiłku poniżej 180 mg/dL) zamiast ogólnych zakresów. Zmienia to tylko cel, względem którego jesteś oceniany — to nigdy nie jest diagnoza.",
- "diabetesSaved": "Zapisano preferencję celu glukozy"
+ }
},
"admin": {
"title": "Administracja",
@@ -6405,7 +6396,6 @@
"pushLocation": "(u {location})",
"edit": "Edytuj",
"originCoach": "Coach",
- "enabledToggleAria": "Włącz lub wyłącz to przypomnienie",
"until": "Do {date}",
"selfPlanned": "Zaplanowane samodzielnie",
"captureValue": "Zapisz wartość",
From 3ec9aca798ab6f5924bd69b48a7248b6ff0b6280 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 22:42:14 +0200
Subject: [PATCH 66/71] perf(dashboard): memoise the band math and the
module-list parse
The dashboard recomputed the client band fallback on every render even when
the server snapshot supplied bands, and the module-list-prefs hook reparsed
its stored JSON into a fresh object each render, defeating downstream
memoisation. Wrap both in useMemo keyed on their real inputs.
---
src/app/page.tsx | 19 ++++++++++++++-----
src/lib/module-list-prefs.ts | 8 ++++++--
2 files changed, 20 insertions(+), 7 deletions(-)
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 4f267de25..d021b42b3 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -532,11 +532,20 @@ export default function DashboardPage() {
// — it is pure + cheap — so a still-loading snapshot frame keeps real
// bands rather than blank charts; `serverBands ?? clientBands` then
// prefers the authoritative server numbers the moment they arrive.
- const clientBands = buildDashboardBands({
- dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null,
- gender: (user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null,
- heightCm: user?.heightCm ?? null,
- });
+ // Inputs only change with profile facts, so memoise the band math — it
+ // ran on every dashboard render (and got discarded by `serverBands ??`
+ // in the snapshot steady state) before this. Keyed on the `user` object
+ // identity so the React Compiler can preserve the memo (a narrower
+ // property list trips `preserve-manual-memoization`).
+ const clientBands = useMemo(
+ () =>
+ buildDashboardBands({
+ dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null,
+ gender: (user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null,
+ heightCm: user?.heightCm ?? null,
+ }),
+ [user],
+ );
const bands = serverBands ?? clientBands;
const bpTargets = bands.bpTargets;
const weightRange = bands.weightRange;
diff --git a/src/lib/module-list-prefs.ts b/src/lib/module-list-prefs.ts
index c23a396ce..0b2e5f4a7 100644
--- a/src/lib/module-list-prefs.ts
+++ b/src/lib/module-list-prefs.ts
@@ -19,7 +19,7 @@
* block (the server default order is preserved for the tail).
*/
-import { useCallback, useSyncExternalStore } from "react";
+import { useCallback, useMemo, useSyncExternalStore } from "react";
export const MODULE_LIST_VIEWS = ["cards", "list"] as const;
export type ModuleListView = (typeof MODULE_LIST_VIEWS)[number];
@@ -150,7 +150,11 @@ export function useModuleListPrefs(module: ModuleListKey): {
}, [key]);
const raw = useSyncExternalStore(subscribe, getSnapshot, () => null);
- const prefs = parseModuleListPrefs(raw);
+ // `useSyncExternalStore` returns a stable `raw` string when storage hasn't
+ // changed, so keying the parse on it gives downstream `applyOrder`
+ // memoisation a stable `prefs` reference instead of a fresh object + a
+ // JSON.parse on every render.
+ const prefs = useMemo(() => parseModuleListPrefs(raw), [raw]);
const write = useCallback(
(next: ModuleListPrefs) => {
From 22f588b34d913f7124fed787858d17e61684dcae Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 22:42:22 +0200
Subject: [PATCH 67/71] refactor(ui): correct stale comments and steady the
settings-nav spacer
Drop the overstated claim that the due-reminder green button mirrors the
medication card (the med take button is always the default tone), fix the
module-tour anchor attribute named in a doc comment, and feed the active
section title into the settings nav spacer so its heading line wraps the same
way a long localized title does.
---
src/components/onboarding/module-tour-trigger.tsx | 2 +-
.../settings/section-nav-heading-spacer.tsx | 11 +++++++++--
src/components/settings/settings-shell.tsx | 12 ++++++++++--
3 files changed, 20 insertions(+), 5 deletions(-)
diff --git a/src/components/onboarding/module-tour-trigger.tsx b/src/components/onboarding/module-tour-trigger.tsx
index a502cbf16..717a3fa01 100644
--- a/src/components/onboarding/module-tour-trigger.tsx
+++ b/src/components/onboarding/module-tour-trigger.tsx
@@ -9,7 +9,7 @@
* opens the spotlight narrowed to that single module card on the
* current page (no cross-page navigation, no completion flip).
*
- * Anchors to the page's `data-tour="<…>-hero"` element via the launcher
+ * Anchors to the page's `data-tour-id="<…>-hero"` element via the launcher
* → overlay path. The button itself carries no anchor — it only fires
* the event.
*/
diff --git a/src/components/settings/section-nav-heading-spacer.tsx b/src/components/settings/section-nav-heading-spacer.tsx
index 5a5bcf962..e8d7c13e6 100644
--- a/src/components/settings/section-nav-heading-spacer.tsx
+++ b/src/components/settings/section-nav-heading-spacer.tsx
@@ -14,11 +14,18 @@
*
* It is only rendered on `md+` (the desktop sticky sidebar); the mobile chip
* strip has no heading-to-align-with.
+ *
+ * v1.18.6 (L11) — pass the active section's resolved `title` so the h1 line
+ * wraps identically when a locale's title runs to two lines, rather than
+ * reserving a single ` ` line. The subtitle copy lives per-page in the
+ * section frame and is not threaded up here, so the spacer reserves one
+ * subtitle line; a subtitle that wraps to two lines in a given locale is the
+ * residual known limitation the original comment called out.
*/
-export function SectionNavHeadingSpacer() {
+export function SectionNavHeadingSpacer({ title }: { title?: string }) {
return (
-
+
{title || " "}
);
diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx
index f99b74e2f..fd6c3464e 100644
--- a/src/components/settings/settings-shell.tsx
+++ b/src/components/settings/settings-shell.tsx
@@ -262,6 +262,9 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
const visibleSections = SETTINGS_SECTIONS.filter(
(section) => !section.moduleGate || modules?.[section.moduleGate] !== false,
);
+ const activeSection = visibleSections.find(
+ (section) => section.slug === activeSlug,
+ );
// v1.4.33 IW4 — keep the active chip in view inside the horizontal
// mobile strip. On a 393 CSS px viewport the strip is wider than the
@@ -360,8 +363,13 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
{/* v1.18.6 (W9) — push the first nav item down to the top of the
first card (level with the page heading's following gap), so
- the menu reads symmetric with the content column. */}
-
+ the menu reads symmetric with the content column. Feed the
+ active title so the spacer's h1 wraps the same way (L11). */}
+
{visibleSections.map((section) => {
const isActive = section.slug === activeSlug;
From 8ff3b51ba396c17dd7b1d87a00bbebf85c428a03 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 22:42:27 +0200
Subject: [PATCH 68/71] refactor: remove dead exports and an unused prop
Drop the health-score card's inert onAskCoach prop and its now-dead
pass-through from the hero strip, the buildDashboardBands re-export that no
caller reached through the snapshot module, an unused tour-progress status
type, and an orphan service-worker cache helper.
---
.../__tests__/health-score-card.test.tsx | 21 +++++--------------
src/components/insights/health-score-card.tsx | 11 ++--------
src/components/insights/hero-strip.tsx | 10 ---------
src/lib/dashboard/snapshot.ts | 3 +--
src/lib/onboarding/tour-progress.ts | 2 --
src/lib/pwa/query-persister.ts | 21 -------------------
6 files changed, 8 insertions(+), 60 deletions(-)
diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx
index 0ed0a1eb0..af2a04019 100644
--- a/src/components/insights/__tests__/health-score-card.test.tsx
+++ b/src/components/insights/__tests__/health-score-card.test.tsx
@@ -194,10 +194,10 @@ describe("", () => {
it("v1.4.27 B1 — no longer mounts an inline Ask-the-Coach button (hero strip carries the CTA)", () => {
// The hero strip's existing "Ask the coach" action covers this
- // surface; the card-internal duplicate button retired. The
- // onAskCoach prop stays on the type signature so callers don't
- // break, but the card no longer renders it.
- const omitted = ssr(
+ // surface; the card-internal duplicate button retired. v1.18.6 (L13)
+ // dropped the now-dead `onAskCoach` prop too — the card renders no
+ // Coach affordance at all.
+ const html = ssr(
", () => {
delta={null}
/>,
);
- const supplied = ssr(
- {}}
- />,
- );
- expect(omitted).not.toContain('data-slot="health-score-card-ask-coach"');
- expect(supplied).not.toContain('data-slot="health-score-card-ask-coach"');
+ expect(html).not.toContain('data-slot="health-score-card-ask-coach"');
});
@@ -226,7 +216,6 @@ describe("", () => {
band="green"
components={baseComponents}
delta={5}
- onAskCoach={() => {}}
/>,
"de",
);
diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx
index 2782b5871..3e058eed6 100644
--- a/src/components/insights/health-score-card.tsx
+++ b/src/components/insights/health-score-card.tsx
@@ -26,8 +26,8 @@ import { HealthScoreDeltaExplainer } from "./health-score-delta-explainer";
* at least one component blends entries from more than one source.
*
* Pure presentational — the parent owns the analytics query + the
- * drawer state. The same `onAskCoach` handler is shared with the hero
- * strip's main "Ask the coach" button (different prefill string).
+ * drawer state. The card mounts no inline Coach affordance; the hero
+ * strip carries the single "Ask the coach" entry point.
*/
export type HealthScoreBand = "green" | "yellow" | "red";
@@ -70,7 +70,6 @@ export interface HealthScoreCardProps {
compliance: HealthScoreCardComponent;
};
delta: number | null;
- onAskCoach?: (prefill: string) => void;
/**
* v1.4.25 W8e — opens the provenance accordion on first render.
* The SSR tests use it to exercise the expanded markup without a
@@ -175,12 +174,6 @@ export function HealthScoreCard({
band,
components,
delta,
- // v1.4.27 B1 — onAskCoach stays on the prop signature so the hero
- // strip's existing wire-up doesn't break, but the card no longer
- // mounts an inline button (the hero strip already carries the same
- // action). Destructure-and-ignore is intentional.
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- onAskCoach: _onAskCoach,
initiallyExpanded = false,
moodEnabled = true,
restModeActive = false,
diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx
index f7fdedfac..025c01545 100644
--- a/src/components/insights/hero-strip.tsx
+++ b/src/components/insights/hero-strip.tsx
@@ -353,16 +353,6 @@ export function HeroStrip({
delta={healthScore.delta}
moodEnabled={moodEnabled}
restModeActive={healthScore.restMode?.active ?? false}
- onAskCoach={
- // v1.4.37 W5 — short-circuit the prop drilling even
- // though `HealthScoreCard` retired its inline button in
- // v1.4.27. A future re-addition can't accidentally
- // surface a Coach affordance from this card while the
- // operator has the flag off.
- coachEnabled && onAskCoach
- ? (prefill: string) => onAskCoach(prefill)
- : undefined
- }
/>
)}
diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts
index 668e38944..228c42814 100644
--- a/src/lib/dashboard/snapshot.ts
+++ b/src/lib/dashboard/snapshot.ts
@@ -78,7 +78,6 @@ import {
} from "@/lib/dashboard/meds-today";
import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path";
import {
- buildDashboardBands,
buildTargetBands,
type DashboardTargetBands,
} from "@/lib/dashboard/bands";
@@ -217,7 +216,7 @@ export interface DashboardSnapshotUser {
* personalisation, no height → weight band), matching the client's
* previous null-guards exactly.
*/
-export { buildDashboardBands, buildTargetBands };
+export { buildTargetBands };
export type { DashboardTargetBands };
export interface DashboardSnapshotMoodEntry {
diff --git a/src/lib/onboarding/tour-progress.ts b/src/lib/onboarding/tour-progress.ts
index b186ce7d3..c14c3a7f1 100644
--- a/src/lib/onboarding/tour-progress.ts
+++ b/src/lib/onboarding/tour-progress.ts
@@ -20,8 +20,6 @@ export const TOUR_PROGRESS_STATUSES = [
"skipped",
] as const;
-export type TourProgressStatus = (typeof TOUR_PROGRESS_STATUSES)[number];
-
/**
* The persisted progress shape. `lastStopId` seeds the resume index;
* `completedStopIds` is informational (analytics + future per-module
diff --git a/src/lib/pwa/query-persister.ts b/src/lib/pwa/query-persister.ts
index 2701f6431..302926f17 100644
--- a/src/lib/pwa/query-persister.ts
+++ b/src/lib/pwa/query-persister.ts
@@ -301,27 +301,6 @@ export async function clearPersistedQueryCache(): Promise {
}
}
-/**
- * Drop the service-worker offline read-data cache (`healthlog-data-*`). It
- * holds the last-synced dashboard JSON and must never survive a session end on
- * a shared device. Scoped to the data cache only — the static cache (hashed
- * chunks, icons) carries no PII and dropping it would force a needless
- * re-download. Best-effort; never throws.
- */
-export async function clearServiceWorkerDataCache(): Promise {
- if (typeof caches === "undefined") return;
- try {
- const keys = await caches.keys();
- await Promise.all(
- keys
- .filter((k) => k.startsWith("healthlog-data-"))
- .map((k) => caches.delete(k)),
- );
- } catch {
- /* best effort */
- }
-}
-
/**
* Wipe every client-side cache that can hold one account's health data: the
* IndexedDB query snapshot, the SW offline read-data cache, and the SW page
From 1118d36cf8212f93728d734c604a9269b16a6c30 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 22:45:26 +0200
Subject: [PATCH 69/71] docs: consolidate the v1.18.6 changelog for the full
cycle
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Cover the whole release — offline reads, cited reference ranges + Coach
grounding, responsible safety alerts + diabetes opt-in, the module tour,
the module-consistency and settings/admin IA pass, sleep/coach polish,
and the reminder completion route — and correct the additive migration
note (0173–0177).
---
CHANGELOG.md | 36 +++++++++++++++++++++++++++++-------
1 file changed, 29 insertions(+), 7 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 309d35660..d73bcbf86 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,26 +2,48 @@
## [Unreleased]
-## [1.18.6] — 2026-06-18 — a real offline app and a faster dashboard
+## [1.18.6] — 2026-06-18 — offline reads, grounded guidance, and one consistent app
-This release makes the installed app genuinely useful offline and lightens the dashboard. Opened without a connection, the home-screen app now shows your last synced data instead of empty placeholders, and a quiet line tells you it's the last sync. The dashboard fetches all its charts in one request instead of one per chart, and resolves its target ranges on the server. Plus a mobile touch-target pass and a reduced-motion safeguard. No breaking changes; no migration.
+A large release that makes the app genuinely useful offline, gives every metric cited reference context, grounds the AI Coach and Daily Briefing in that same context, adds responsible safety alerts for clinically urgent readings, and brings the newer modules (preventive care, illness, labs) and the whole settings area in line with the established patterns. A guided tour now explains what each module does and how they connect. No breaking changes; all schema changes are additive.
### Added
-- **Real offline reads.** The installed app caches your dashboard, recent readings, medications and insights (safe, read-only data only — never anything tied to sign-in), so opening it offline shows your last synced view with a "showing your last synced data" note, rather than blank skeletons. Changes you make offline still save when you reconnect.
+- **A module-by-module guided tour.** After onboarding, a short tour walks each module — what it does, one capability you might miss, and how it feeds the others — so the whole app makes sense on day one. Replayable any time from Settings, or per module from its page.
+- **Cited reference ranges everywhere.** Metrics now carry plain-language explainers and the general reference range they sit in (blood pressure, resting heart rate, glucose, blood oxygen, temperature, and more), framed as general guidance and sourced from recognised guidelines — never a diagnosis. Blood pressure follows the ESH range. The blood-pressure detail now also shows pulse pressure and mean arterial pressure, derived from your readings.
+- **A Coach that knows the ranges.** The AI Coach and Daily Briefing are now grounded in those cited ranges, so a comment about a value reflects where it sits in the general range — still general guidance, never a diagnosis, with no third-party attribution.
+- **Proactive Coach check-ins you can actually see.** A proactive nudge now appears as a message in your Coach conversation (not only as a push), with a discreet unread dot on the Coach button that clears when you open it — so it works even without notifications set up.
+- **Responsible safety alerts.** A confirmed, re-measured reading in a clinically urgent range (very high or low blood pressure, low or high glucose) now raises an alert that says to re-check or contact your doctor — never a diagnosis, and only for the modules you use.
+- **A diabetes setting.** An explicit opt-in switches glucose targets to the tighter clinical goal range; it is never inferred from your readings.
+- **Per-module settings for preventive care, illness and labs.** Each gets its own settings page to reorder items and switch between card and list view; labs additionally lets you manage biomarkers and choose the sort order.
+- **Mark a reminder done.** A measurement reminder can be completed server-side (for the apps), instead of only being dismissed locally.
+- **Real offline reads.** Opened without a connection, the installed app shows your last synced dashboard and recent data with a "showing your last synced data" note, rather than blank placeholders. Changes you make offline still save when you reconnect.
### Changed
-- **A faster dashboard.** All dashboard charts now load from a single batched request instead of one fetch per chart, and target ranges are computed on the server — less work on your device and lighter load on the database.
-- **Mobile polish.** Small tap targets (reminder-time chips, the dose-history and token-copy buttons, sortable headers) were brought up to a comfortable touch size, and a global reduced-motion safeguard now quiets animation when your device asks for it.
+- **Preventive-care cards match the medication cards.** A clear "measure now" action, the interval as a chip, and progress toward the next due date — with measurement-appropriate wording (no more "intake" for a blood-pressure check) and the same calm, neutral card.
+- **One consistent "add" and a settings wrench** across preventive care, illness and labs; the illness journal's resolved entries are collapsed by default.
+- **Every Settings and Admin page now has a clear heading and description**, in one shared frame, with the side menu lined up to the content — so the whole area reads as one piece. The AI section is now labelled "AI provider", and module availability has its own admin page.
+- **No more AI dead-end.** When no AI provider is connected, onboarding and the Coach/Insights/Briefing screens explain what the feature does and how to set it up, instead of a bare error.
+- **Clearer sleep, recovery and insights.** The estimated sleep-stage timeline is shown only when it reflects measured stages (with an honest note otherwise); the chronotype reads as labelled text; the sleep-quality assessment is more useful; the recovery page is de-cluttered; and insight cards link through to their detail pages, including steps.
+- **A faster dashboard.** All charts load in one batched request instead of one per chart, with target ranges computed on the server.
+- **Fewer disclaimers, one clear acknowledgment.** The repeated "not medical advice" banners are gone in favour of a single acknowledgment at onboarding (reachable any time in Settings).
+- **Mobile polish.** Small tap targets were brought up to a comfortable size, and a global reduced-motion safeguard quiets animation when your device asks for it.
+
+### Fixed
+
+- **Reduced motion no longer hides content.** Charts and list rows that animate in now stay visible when the device requests reduced motion, instead of disappearing.
+- **Read-after-write.** A reading or medication you just added now reliably appears in its list.
+- **A timezone bug** that anchored the local day at UTC midnight for one intake path.
+- **Contrast.** The preventive-care action button now meets AA contrast in dark mode.
### Security
-- The container scan ignores a known `picomatch` advisory (CVE-2026-33671) that exists only in the npm CLI bundled inside the base image, not in the app's own dependencies (which are pinned to the fixed version) — clearing a false-positive in the non-blocking security scan.
+- **The offline cache is hardened.** It stores only a safe, read-only allowlist (never anything tied to sign-in or sensitive AI conversations), is bound to the signed-in account, and is wiped when the session ends — not only on explicit sign-out.
+- The container scan ignores a known `picomatch` advisory (CVE-2026-33671) that exists only in the npm CLI bundled inside the base image, not in the app's own dependencies (pinned to the fixed version).
### Operator note
-- UI/API-only; no migration. The service worker cache version bumps with the release, so clients refresh their offline cache automatically on update.
+- Additive migrations only (`0173`–`0177`: tour progress, diabetes opt-in, disclaimer acknowledgment, Coach last-seen, a Coach-message index). The optional `APNS_CRITICAL_ENTITLEMENT` env var (introduced in 1.18.4) gates native critical alerts; urgent alerts ship as time-sensitive without it. The service-worker cache version bumps with the release, so clients refresh their offline cache automatically on update.
## [1.18.5] — 2026-06-18 — filter by value, a tidier advanced panel, and a titration timeline for injectables
From 742faffdda6e9db3c329457055130f22ef17dc3f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 22:55:22 +0200
Subject: [PATCH 70/71] refactor(dashboard): drop the duplicate band-builder
export
buildTargetBands was a same-file alias of buildDashboardBands, which the
dead-code gate flags as a duplicate export. Keep the historic name only as
a re-export alias from the snapshot module, so callers and the parity test
are unchanged while bands.ts exposes a single builder.
---
src/lib/dashboard/bands.ts | 9 +++------
src/lib/dashboard/snapshot.ts | 2 +-
2 files changed, 4 insertions(+), 7 deletions(-)
diff --git a/src/lib/dashboard/bands.ts b/src/lib/dashboard/bands.ts
index 8f2d1aed0..d7d7ad397 100644
--- a/src/lib/dashboard/bands.ts
+++ b/src/lib/dashboard/bands.ts
@@ -135,9 +135,6 @@ export function buildDashboardBands(profile: {
};
}
-/**
- * Snapshot-era alias. The snapshot builder and its parity test still
- * import `buildTargetBands`; keep the name pointing at the canonical
- * builder so neither has to change.
- */
-export const buildTargetBands = buildDashboardBands;
+// The snapshot builder re-exports `buildDashboardBands` under the historic
+// `buildTargetBands` name (see dashboard/snapshot.ts) so callers and the
+// parity test keep working without a duplicate export living in this file.
diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts
index 228c42814..4a6b809aa 100644
--- a/src/lib/dashboard/snapshot.ts
+++ b/src/lib/dashboard/snapshot.ts
@@ -78,7 +78,7 @@ import {
} from "@/lib/dashboard/meds-today";
import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path";
import {
- buildTargetBands,
+ buildDashboardBands as buildTargetBands,
type DashboardTargetBands,
} from "@/lib/dashboard/bands";
import type {
From 8b03fb450e383c74ec392b576e8fd9e2e76f2355 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 18 Jun 2026 23:04:27 +0200
Subject: [PATCH 71/71] fix(deps): patch undici and nodemailer high-severity
advisories
Bump undici 8.4.1 -> 8.5.0 (GHSA-vmh5-mc38-953g, TLS certificate
validation bypass in the SOCKS5 ProxyAgent) and nodemailer 9.0.0 -> 9.0.1
(GHSA-p6gq-j5cr-w38f), both direct dependencies. Clears the production
high-severity dependency audit; no API change.
---
package.json | 4 ++--
pnpm-lock.yaml | 20 ++++++++++----------
2 files changed, 12 insertions(+), 12 deletions(-)
diff --git a/package.json b/package.json
index 7d097d9ba..ca1f3164d 100644
--- a/package.json
+++ b/package.json
@@ -73,11 +73,11 @@
"mmdb-lib": "^3.0.2",
"next": "16.2.6",
"next-themes": "^0.4.6",
+ "nodemailer": "^9.0.1",
"p-limit": "^7.3.0",
"pg-boss": "^12.18.2",
"prisma": "^7.8.0",
"qrcode": "^1.5.4",
- "nodemailer": "^9.0.0",
"radix-ui": "^1.4.3",
"react": "19.2.7",
"react-dom": "19.2.7",
@@ -87,7 +87,7 @@
"sax": "^1.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
- "undici": "^8.4.1",
+ "undici": "^8.5.0",
"web-push": "^3.6.7",
"yaml": "^2.8.4",
"zod": "^4.4.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 15afc4d70..a1db36f88 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -82,8 +82,8 @@ importers:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
nodemailer:
- specifier: ^9.0.0
- version: 9.0.0
+ specifier: ^9.0.1
+ version: 9.0.1
p-limit:
specifier: ^7.3.0
version: 7.3.0
@@ -124,8 +124,8 @@ importers:
specifier: ^3.6.0
version: 3.6.0
undici:
- specifier: ^8.4.1
- version: 8.4.1
+ specifier: ^8.5.0
+ version: 8.5.0
web-push:
specifier: ^3.6.7
version: 3.6.7
@@ -5196,8 +5196,8 @@ packages:
resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==}
engines: {node: '>=18'}
- nodemailer@9.0.0:
- resolution: {integrity: sha512-tbPTid7d/p9jAA8CRZ3iomvrMaST0o6NYuY7v6JQZHpPRZ61mLFSPKYd7342NtOFuej9/+L48SOIxwfu2uDvtw==}
+ nodemailer@9.0.1:
+ resolution: {integrity: sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==}
engines: {node: '>=6.0.0'}
non-error@0.1.0:
@@ -6345,8 +6345,8 @@ packages:
resolution: {integrity: sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==}
engines: {node: '>=20.18.1'}
- undici@8.4.1:
- resolution: {integrity: sha512-RNHlB4fxZK0IrkhBsxhlbx7s8kFWwr7rzzOqj5nvZugw3ig3RsB7KW3zVlV0eu8POl+rx5d1hmL7rRg0z1owow==}
+ undici@8.5.0:
+ resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==}
engines: {node: '>=22.19.0'}
unicorn-magic@0.3.0:
@@ -11834,7 +11834,7 @@ snapshots:
node-releases@2.0.46: {}
- nodemailer@9.0.0: {}
+ nodemailer@9.0.1: {}
non-error@0.1.0: {}
@@ -13233,7 +13233,7 @@ snapshots:
undici@7.26.0: {}
- undici@8.4.1: {}
+ undici@8.5.0: {}
unicorn-magic@0.3.0: {}