Merge pull request #363 from MBombeck/release/v1.21.0

Release v1.21.0
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-26 14:43:40 +02:00
commit 85a0819eac
173 files changed
+8983 -825

No files matched your search

+29
View File
@@ -2,6 +2,35 @@
## [Unreleased]
## [1.21.0] — 2026-06-26 — Date-format preference, a far more connected Coach, and broad correctness work
A feature release. The Coach reaches every data domain on demand, surfaces the cross-metric patterns the analytics tier already discovers, opens in context from any screen, and speaks with a warmer, forward-looking voice on one shared set of safety thresholds. Dates render in your chosen format everywhere. Two additive migrations (`0193` rollup x-rescale, `0192` date format); no breaking changes.
### Added
- A date-format preference in the profile — automatic (follows the language), day-month-year, month-day-year, or ISO — honoured across the app, including every date and date-time field, which now render in your chosen order regardless of the browser's locale.
- The Coach answers from the full data picture: every metric domain on demand plus cycle and workouts, and it surfaces the discovered cross-metric correlations — medication adherence against symptoms, short sleep against the next morning's vitals, and so on — citing only what the analytics tier actually found.
- Open the Coach in context from any metric page or insight card and it arrives already scoped to what you were looking at, with a relevant opening question.
- "Learn more" pointers on the vitals tiles, the glucose panel, the resilience tile, and the lab biomarker detail link out to the matching guide; the Coach references the same guides and can no longer offer a link that doesn't exist.
- Medication compliance and symptom severity are now first-class signals in the correlation engine, so an adherence dip that tracks a symptom flare can finally be surfaced.
### Changed
- The Coach connects signals into one story instead of listing metrics, looks ahead with gentle, ranged outlooks, ends an action turn by checking your confidence and offering a single doable step, and keeps affirmation earned. A closed acute-symptom clause points to prompt medical attention for crisis signs.
- Critical-threshold numbers — blood pressure, fever, glucose — now come from one source, so the dashboard banner, the Coach, the status cards, and the notifications always state the same thresholds.
- The trend regression is composed on an origin-rescaled, mean-centred basis, removing a floating-point cancellation on long windows; the rollup now matches the live computation to the last digit.
- The daily AI usage budget is provider-aware: usage on your own OpenAI key, ChatGPT plan, or local model is no longer limited by the server-cost ceiling that applies to an operator-provided key.
- The Coach builds its data snapshot once per turn and may take an extra reasoning round for a deep cross-metric question.
- Discovered correlations are held to an effect-size floor and a confidence tier, and sparse personal signals are shrunk toward the baseline, so a real-but-trivial association is no longer stated as a confident driver.
### Fixed
- ChatGPT/OpenAI sign-in users no longer hit a spurious "daily limit reached" after only a couple of messages.
- The symptom journal's recovery-return and the SpO2 red-flag count consecutive calendar days and are order-independent; the "two or more vitals out of band today" flag is keyed to your calendar day rather than UTC.
- Withings activity sync and the medication-intake dedup issue far fewer queries on a long backfill.
- The trend read returns the same boundary day whether served from the rollup or from a live query.
- The native date primitives that ignored the locale are gone — every field routes through the format-aware inputs.
## [1.20.2] — 2026-06-26 — Hardening across insights, safety flags, and integrations
A patch release. A broad pass over the server tier and the AI surfaces: a few correctness and safety fixes, a numerical hardening of the trend tier, and several smaller robustness and cost improvements. No schema changes.
+30 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.20.2
version: 1.21.0
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -8184,6 +8184,15 @@ components:
- AUTO
- H12
- H24
dateFormat:
description: Date-order display preference. AUTO follows the locale convention, DMY pins day-month-year (dd.MM.yyyy),
MDY pins month-day-year (MM/dd/yyyy), YMD pins ISO yyyy-MM-dd.
type: string
enum:
- AUTO
- DMY
- MDY
- YMD
moodReminderEnabled:
type: boolean
fullName:
@@ -15784,6 +15793,15 @@ components:
- H12
- H24
description: Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.
dateFormat:
type: string
enum:
- AUTO
- DMY
- MDY
- YMD
description: Date-order display preference. AUTO follows the locale convention, DMY pins day-month-year (dd.MM.yyyy),
MDY pins month-day-year (MM/dd/yyyy), YMD pins ISO yyyy-MM-dd.
moodReminderEnabled:
type: boolean
fullName:
@@ -15815,6 +15833,7 @@ components:
- locale
- timezone
- timeFormat
- dateFormat
- moodReminderEnabled
- fullName
- insurerName
@@ -15885,6 +15904,15 @@ components:
- H12
- H24
description: Hour-cycle display preference. AUTO follows the locale convention, H12 forces AM/PM, H24 forces 24-hour.
dateFormat:
type: string
enum:
- AUTO
- DMY
- MDY
- YMD
description: Date-order display preference. AUTO follows the locale convention, DMY pins day-month-year (dd.MM.yyyy),
MDY pins month-day-year (MM/dd/yyyy), YMD pins ISO yyyy-MM-dd.
moodReminderEnabled:
type: boolean
fullName:
@@ -15912,6 +15940,7 @@ components:
- locale
- timezone
- timeFormat
- dateFormat
- moodReminderEnabled
- fullName
- insurerName
+3
View File
@@ -59,6 +59,9 @@ test.describe("medication wizard — daily", () => {
// Step 4 — course window (today by default).
await expectStep(page, 4);
// `DateField` keeps the ISO value on a hidden native date input
// (data-slot), while the visible overlay paints the locale-formatted
// string. Assert the committed ISO value on the hidden input.
await expect(
page.locator('[data-slot="course-window-starts"]'),
).toHaveValue(/^\d{4}-\d{2}-\d{2}$/);
+9 -1
View File
@@ -62,7 +62,15 @@ test.describe("medication wizard — one-shot", () => {
// Step 4 — course window. Set startsOn to a deterministic date so
// the post body assertion below is stable.
await expectStep(page, 4);
await page.locator('[data-slot="course-window-starts"]').fill("2026-10-15");
// `DateField` rides a visible text overlay (the data-testid) that parses
// a typed ISO string back to the canonical value; the hidden native input
// (data-slot) carries the committed ISO. Type into the overlay, then blur
// so the parse commits.
await page.getByTestId("course-window-starts-field").fill("2026-10-15");
await page.getByTestId("course-window-starts-field").blur();
await expect(
page.locator('[data-slot="course-window-starts"]'),
).toHaveValue("2026-10-15");
await clickNext(page);
// Step 5 — pick Einmalig. The path compresses to 5 steps the
+19 -12
View File
@@ -48,7 +48,16 @@ test.describe("Settings mobile consistency (Pixel 5)", () => {
)
.evaluateAll((els) =>
els.map((el) => {
const rect = el.getBoundingClientRect();
// DateField / DateTimeField front an sr-only native input with a
// formatted overlay; the 44px tap target is the wrapper, while the
// overlay sits inside the wrapper's border (≈42px). Measure the
// wrapper for any input inside one so the target-size reflects the
// real affordance, not the inner content box.
const wrapper = el.closest(
'[data-slot="date-field"],[data-slot="date-time-field"]',
);
const measured = wrapper ?? el;
const rect = measured.getBoundingClientRect();
const style = getComputedStyle(el);
return {
id: el.id || el.getAttribute("name") || "",
@@ -64,17 +73,15 @@ test.describe("Settings mobile consistency (Pixel 5)", () => {
}),
)
).filter(
// The avatar profile-photo upload uses the accessible
// visually-hidden <input type="file"> pattern: the input itself
// is sr-only / zero-size and the real 44 px touch target is the
// styled label/button that triggers it. The touch-target sweep
// must measure that visible affordance, not the hidden input, so
// exempt file inputs that are hidden or collapsed to zero size.
(inp) =>
!(
inp.type === "file" &&
(inp.hidden || inp.height === 0 || inp.width === 0)
),
// Some inputs use the accessible visually-hidden pattern: the input
// itself is sr-only / zero-size and the real 44 px touch target is a
// sibling visible affordance — the styled label/button for the avatar
// <input type="file">, and the formatted overlay that fronts the
// sr-only native <input type="date"|"datetime-local"> inside DateField /
// DateTimeField. The touch-target sweep must measure those visible
// affordances, not the hidden input, so exempt any input that is
// visually hidden or collapsed to zero size.
(inp) => !(inp.hidden || inp.height === 0 || inp.width === 0),
);
expect(formInputs.length).toBeGreaterThan(0);
+17 -4
View File
@@ -20,6 +20,7 @@
"inactive": "Inaktiv",
"disabled": "Deaktiviert",
"networkError": "Netzwerkfehler",
"openDatePicker": "Datumsauswahl öffnen",
"unknownError": "Unbekannter Fehler",
"copied": "Kopiert!",
"or": "oder",
@@ -33,7 +34,8 @@
"retry": "Erneut versuchen",
"reportIssue": "Fehler melden",
"showPassword": "Passwort anzeigen",
"hidePassword": "Passwort verbergen"
"hidePassword": "Passwort verbergen",
"learnMore": "Mehr erfahren"
},
"doctorReport": {
"title": "Gesundheitsbericht",
@@ -2223,7 +2225,7 @@
"feedbackThanks": "Danke für das Signal.",
"feedbackError": "Feedback konnte nicht gespeichert werden",
"dailyLimitTitle": "Heute-Limit erreicht",
"dailyLimitBody": "Heute-Limit erreicht; Reset um 00:00 UTC.",
"dailyLimitBody": "Tageslimit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.",
"providerRateLimitTitle": "Provider rate-limited",
"providerRateLimitBody": "Provider ist temporär überlastet; Reset in ~5 min.",
"cluster": {
@@ -2278,14 +2280,15 @@
"dictateError": "Spracheingabe konnte nicht gestartet werden. Prüfe die Mikrofonberechtigung und versuche es erneut.",
"showConversations": "Unterhaltungen anzeigen",
"hideConversations": "Unterhaltungen ausblenden",
"heroGreeting": "Wie kann ich dir helfen?",
"heroGreeting": "Frage mich etwas zu deinen Daten",
"heroSubline": "Frag nach deinen Verläufen, Medikamenten oder Messwerten.",
"thinkingElapsed": "{seconds}s",
"thinkingDuration": "Nachgedacht für {seconds}s",
"thinkingExpandAria": "Zeigen, was der Coach berücksichtigt hat",
"thinkingDetail": "Beantwortet aus deinem Gesundheits-Snapshot.",
"tokensUsed": "{count} Tokens",
"tokensUsedWithModel": "{count} Tokens · {model}"
"tokensUsedWithModel": "{count} Tokens · {model}",
"askAboutThis": "Den Coach dazu fragen"
},
"relativeJustNow": "gerade eben",
"relativeMinutesAgoOne": "vor {count} Minute",
@@ -4120,6 +4123,16 @@
"saved": "Stundenformat gespeichert.",
"saveError": "Speichern des Stundenformats fehlgeschlagen."
},
"dateFormat": {
"title": "Datumsformat",
"description": "Bestimmt die Reihenfolge von Tag, Monat und Jahr bei der Anzeige von Daten.",
"auto": "Automatisch (Sprache)",
"dmy": "TT.MM.JJJJ",
"mdy": "MM/TT/JJJJ",
"ymd": "JJJJ-MM-TT",
"saved": "Datumsformat gespeichert.",
"saveError": "Speichern des Datumsformats fehlgeschlagen."
},
"username": "Benutzername",
"height": "Körpergröße (cm)",
"dateOfBirth": "Geburtsdatum",
+17 -4
View File
@@ -20,6 +20,7 @@
"inactive": "Inactive",
"disabled": "Disabled",
"networkError": "Network error",
"openDatePicker": "Open date picker",
"unknownError": "Unknown error",
"copied": "Copied!",
"or": "or",
@@ -33,7 +34,8 @@
"retry": "Retry",
"reportIssue": "Report issue",
"showPassword": "Show password",
"hidePassword": "Hide password"
"hidePassword": "Hide password",
"learnMore": "Learn more"
},
"doctorReport": {
"title": "Health Report",
@@ -2223,7 +2225,7 @@
"feedbackThanks": "Thanks for the signal.",
"feedbackError": "Could not save feedback",
"dailyLimitTitle": "Daily limit reached",
"dailyLimitBody": "Daily limit reached; resets at 00:00 UTC.",
"dailyLimitBody": "Daily limit reached. The budget refreshes at midnight UTC.",
"providerRateLimitTitle": "Provider rate-limited",
"providerRateLimitBody": "Provider temporarily rate-limited; retry in ~5 min.",
"cluster": {
@@ -2278,14 +2280,15 @@
"dictateError": "Could not start voice input. Check microphone permission and try again.",
"showConversations": "Show conversations",
"hideConversations": "Hide conversations",
"heroGreeting": "How can I help you?",
"heroGreeting": "Ask me anything about your data",
"heroSubline": "Ask about your trends, medications, or readings.",
"thinkingElapsed": "{seconds}s",
"thinkingDuration": "Thought for {seconds}s",
"thinkingExpandAria": "Show what the Coach considered",
"thinkingDetail": "Answered from your health snapshot.",
"tokensUsed": "{count} tokens",
"tokensUsedWithModel": "{count} tokens · {model}"
"tokensUsedWithModel": "{count} tokens · {model}",
"askAboutThis": "Ask the coach about this"
},
"relativeJustNow": "just now",
"relativeMinutesAgoOne": "{count} minute ago",
@@ -4120,6 +4123,16 @@
"saved": "Hour format saved.",
"saveError": "Saving the hour format failed."
},
"dateFormat": {
"title": "Date format",
"description": "Controls the order of day, month and year when dates are shown.",
"auto": "Automatic (language)",
"dmy": "DD.MM.YYYY",
"mdy": "MM/DD/YYYY",
"ymd": "YYYY-MM-DD",
"saved": "Date format saved.",
"saveError": "Saving the date format failed."
},
"username": "Username",
"height": "Height (cm)",
"dateOfBirth": "Date of birth",
+17 -4
View File
@@ -20,6 +20,7 @@
"inactive": "Inactivo",
"disabled": "Desactivado",
"networkError": "Error de red",
"openDatePicker": "Abrir el selector de fecha",
"unknownError": "Error desconocido",
"copied": "¡Copiado!",
"or": "o",
@@ -33,7 +34,8 @@
"retry": "Reintentar",
"reportIssue": "Reportar problema",
"showPassword": "Mostrar contraseña",
"hidePassword": "Ocultar contraseña"
"hidePassword": "Ocultar contraseña",
"learnMore": "Más información"
},
"doctorReport": {
"title": "Informe de salud",
@@ -2221,7 +2223,7 @@
"feedbackThanks": "Gracias por la indicación.",
"feedbackError": "No se pudo guardar el comentario",
"dailyLimitTitle": "Límite diario alcanzado",
"dailyLimitBody": "Límite diario alcanzado; reinicio a las 00:00 UTC.",
"dailyLimitBody": "Límite diario alcanzado. El cupo se reinicia a medianoche (UTC).",
"providerRateLimitTitle": "Proveedor con límite de tasa",
"providerRateLimitBody": "El proveedor está temporalmente saturado; reinicio en ~5 min.",
"settingsContextLabel": "Enviar contexto",
@@ -2278,14 +2280,15 @@
"dictateError": "No se pudo iniciar la entrada de voz. Comprueba el permiso del micrófono e inténtalo de nuevo.",
"showConversations": "Mostrar conversaciones",
"hideConversations": "Ocultar conversaciones",
"heroGreeting": "¿En qué puedo ayudarte?",
"heroGreeting": "Pregúntame lo que quieras sobre tus datos",
"heroSubline": "Pregunta por tus tendencias, medicamentos o mediciones.",
"thinkingElapsed": "{seconds}s",
"thinkingDuration": "Pensó durante {seconds}s",
"thinkingExpandAria": "Mostrar lo que el Coach tuvo en cuenta",
"thinkingDetail": "Respondido a partir de tu resumen de salud.",
"tokensUsed": "{count} tokens",
"tokensUsedWithModel": "{count} tokens · {model}"
"tokensUsedWithModel": "{count} tokens · {model}",
"askAboutThis": "Pregúntale al coach sobre esto"
},
"relativeJustNow": "ahora mismo",
"relativeMinutesAgoOne": "hace {count} minuto",
@@ -4120,6 +4123,16 @@
"saved": "Formato horario guardado.",
"saveError": "No se pudo guardar el formato horario."
},
"dateFormat": {
"title": "Formato de fecha",
"description": "Determina el orden de día, mes y año al mostrar las fechas.",
"auto": "Automático (idioma)",
"dmy": "DD.MM.AAAA",
"mdy": "MM/DD/AAAA",
"ymd": "AAAA-MM-DD",
"saved": "Formato de fecha guardado.",
"saveError": "No se pudo guardar el formato de fecha."
},
"username": "Nombre de usuario",
"height": "Altura (cm)",
"dateOfBirth": "Fecha de nacimiento",
+17 -4
View File
@@ -20,6 +20,7 @@
"inactive": "Inactif",
"disabled": "Désactivé",
"networkError": "Erreur réseau",
"openDatePicker": "Ouvrir le sélecteur de date",
"unknownError": "Erreur inconnue",
"copied": "Copié !",
"or": "ou",
@@ -33,7 +34,8 @@
"retry": "Réessayer",
"reportIssue": "Signaler un problème",
"showPassword": "Afficher le mot de passe",
"hidePassword": "Masquer le mot de passe"
"hidePassword": "Masquer le mot de passe",
"learnMore": "En savoir plus"
},
"doctorReport": {
"title": "Rapport de santé",
@@ -2221,7 +2223,7 @@
"feedbackThanks": "Merci pour l’indication.",
"feedbackError": "Impossible d'enregistrer le retour",
"dailyLimitTitle": "Limite quotidienne atteinte",
"dailyLimitBody": "Limite quotidienne atteinte ; réinitialisation à 00:00 UTC.",
"dailyLimitBody": "Limite quotidienne atteinte. Le quota se réinitialise à minuit (UTC).",
"providerRateLimitTitle": "Fournisseur limité en débit",
"providerRateLimitBody": "Le fournisseur est temporairement saturé ; réinitialisation dans ~5 min.",
"settingsContextLabel": "Envoyer le contexte",
@@ -2278,14 +2280,15 @@
"dictateError": "Impossible de démarrer la saisie vocale. Vérifiez l'autorisation du microphone et réessayez.",
"showConversations": "Afficher les conversations",
"hideConversations": "Masquer les conversations",
"heroGreeting": "Comment puis-je t'aider ?",
"heroGreeting": "Pose-moi une question sur tes données",
"heroSubline": "Pose une question sur tes tendances, médicaments ou mesures.",
"thinkingElapsed": "{seconds}s",
"thinkingDuration": "A réfléchi pendant {seconds}s",
"thinkingExpandAria": "Montrer ce que le Coach a pris en compte",
"thinkingDetail": "Réponse établie à partir de ton aperçu santé.",
"tokensUsed": "{count} jetons",
"tokensUsedWithModel": "{count} jetons · {model}"
"tokensUsedWithModel": "{count} jetons · {model}",
"askAboutThis": "Interroger le coach à ce sujet"
},
"relativeJustNow": "à l’instant",
"relativeMinutesAgoOne": "il y a {count} minute",
@@ -4120,6 +4123,16 @@
"saved": "Format horaire enregistré.",
"saveError": "Échec de l’enregistrement du format horaire."
},
"dateFormat": {
"title": "Format de date",
"description": "Détermine l’ordre du jour, du mois et de l’année lors de l’affichage des dates.",
"auto": "Automatique (langue)",
"dmy": "JJ.MM.AAAA",
"mdy": "MM/JJ/AAAA",
"ymd": "AAAA-MM-JJ",
"saved": "Format de date enregistré.",
"saveError": "Échec de l’enregistrement du format de date."
},
"username": "Nom d'utilisateur",
"height": "Taille (cm)",
"dateOfBirth": "Date de naissance",
+17 -4
View File
@@ -20,6 +20,7 @@
"inactive": "Inattivo",
"disabled": "Disabilitato",
"networkError": "Errore di rete",
"openDatePicker": "Apri il selettore di data",
"unknownError": "Errore sconosciuto",
"copied": "Copiato!",
"or": "o",
@@ -33,7 +34,8 @@
"retry": "Riprova",
"reportIssue": "Segnala un problema",
"showPassword": "Mostra password",
"hidePassword": "Nascondi password"
"hidePassword": "Nascondi password",
"learnMore": "Scopri di più"
},
"doctorReport": {
"title": "Rapporto sanitario",
@@ -2221,7 +2223,7 @@
"feedbackThanks": "Grazie per il segnale.",
"feedbackError": "Impossibile salvare il feedback",
"dailyLimitTitle": "Limite giornaliero raggiunto",
"dailyLimitBody": "Limite giornaliero raggiunto; reset alle 00:00 UTC.",
"dailyLimitBody": "Limite giornaliero raggiunto. Il budget si reimposta a mezzanotte (UTC).",
"providerRateLimitTitle": "Provider con rate-limit",
"providerRateLimitBody": "Il provider è temporaneamente sovraccarico; reset in ~5 min.",
"settingsContextLabel": "Invia contesto",
@@ -2278,14 +2280,15 @@
"dictateError": "Impossibile avviare l'inserimento vocale. Controlla l'autorizzazione del microfono e riprova.",
"showConversations": "Mostra conversazioni",
"hideConversations": "Nascondi conversazioni",
"heroGreeting": "Come posso aiutarti?",
"heroGreeting": "Chiedimi qualcosa sui tuoi dati",
"heroSubline": "Chiedi dei tuoi andamenti, farmaci o misurazioni.",
"thinkingElapsed": "{seconds}s",
"thinkingDuration": "Ha riflettuto per {seconds}s",
"thinkingExpandAria": "Mostra cosa ha considerato il Coach",
"thinkingDetail": "Risposta basata sul tuo riepilogo di salute.",
"tokensUsed": "{count} token",
"tokensUsedWithModel": "{count} token · {model}"
"tokensUsedWithModel": "{count} token · {model}",
"askAboutThis": "Chiedi al coach"
},
"relativeJustNow": "proprio ora",
"relativeMinutesAgoOne": "{count} minuto fa",
@@ -4120,6 +4123,16 @@
"saved": "Formato orario salvato.",
"saveError": "Salvataggio del formato orario non riuscito."
},
"dateFormat": {
"title": "Formato data",
"description": "Determina l’ordine di giorno, mese e anno nella visualizzazione delle date.",
"auto": "Automatico (lingua)",
"dmy": "GG.MM.AAAA",
"mdy": "MM/GG/AAAA",
"ymd": "AAAA-MM-GG",
"saved": "Formato data salvato.",
"saveError": "Salvataggio del formato data non riuscito."
},
"username": "Nome utente",
"height": "Altezza (cm)",
"dateOfBirth": "Data di nascita",
+17 -4
View File
@@ -20,6 +20,7 @@
"inactive": "Nieaktywne",
"disabled": "Wyłączone",
"networkError": "Błąd sieci",
"openDatePicker": "Otwórz wybór daty",
"unknownError": "Nieznany błąd",
"copied": "Skopiowano!",
"or": "lub",
@@ -33,7 +34,8 @@
"retry": "Spróbuj ponownie",
"reportIssue": "Zgłoś problem",
"showPassword": "Pokaż hasło",
"hidePassword": "Ukryj hasło"
"hidePassword": "Ukryj hasło",
"learnMore": "Dowiedz się więcej"
},
"doctorReport": {
"title": "Raport zdrowotny",
@@ -2221,7 +2223,7 @@
"feedbackThanks": "Dzięki za sygnał.",
"feedbackError": "Nie udało się zapisać opinii",
"dailyLimitTitle": "Osiągnięto dzienny limit",
"dailyLimitBody": "Osiągnięto dzienny limit; reset o 00:00 UTC.",
"dailyLimitBody": "Osiągnięto dzienny limit. Limit resetuje się o północy (UTC).",
"providerRateLimitTitle": "Dostawca z ograniczeniem",
"providerRateLimitBody": "Dostawca jest tymczasowo przeciążony; reset za ~5 min.",
"settingsContextLabel": "Wysyłaj kontekst",
@@ -2278,14 +2280,15 @@
"dictateError": "Nie udało się uruchomić wprowadzania głosowego. Sprawdź uprawnienia mikrofonu i spróbuj ponownie.",
"showConversations": "Pokaż rozmowy",
"hideConversations": "Ukryj rozmowy",
"heroGreeting": "W czym mogę pomóc?",
"heroGreeting": "Zapytaj mnie o cokolwiek na temat twoich danych",
"heroSubline": "Zapytaj o swoje trendy, leki lub pomiary.",
"thinkingElapsed": "{seconds}s",
"thinkingDuration": "Myślał przez {seconds}s",
"thinkingExpandAria": "Pokaż, co Coach wziął pod uwagę",
"thinkingDetail": "Odpowiedź na podstawie Twojego podsumowania zdrowia.",
"tokensUsed": "{count} tokenów",
"tokensUsedWithModel": "{count} tokenów · {model}"
"tokensUsedWithModel": "{count} tokenów · {model}",
"askAboutThis": "Zapytaj trenera o to"
},
"relativeJustNow": "przed chwilą",
"relativeMinutesAgoOne": "{count} minutę temu",
@@ -4120,6 +4123,16 @@
"saved": "Zapisano format godziny.",
"saveError": "Nie udało się zapisać formatu godziny."
},
"dateFormat": {
"title": "Format daty",
"description": "Określa kolejność dnia, miesiąca i roku przy wyświetlaniu dat.",
"auto": "Automatycznie (język)",
"dmy": "DD.MM.RRRR",
"mdy": "MM/DD/RRRR",
"ymd": "RRRR-MM-DD",
"saved": "Zapisano format daty.",
"saveError": "Nie udało się zapisać formatu daty."
},
"username": "Nazwa użytkownika",
"height": "Wzrost (cm)",
"dateOfBirth": "Data urodzenia",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.20.2",
"version": "1.21.0",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -0,0 +1,24 @@
-- v1.21.0 — per-user date display preference.
--
-- The UI locale alone decided whether dates rendered as dd.MM.yyyy or
-- MM/dd/yyyy, and the native date input fell back to the browser locale,
-- which left non-US self-hosters on MM/DD/YYYY with no way out. The new
-- column carries an explicit preference: AUTO follows the locale
-- convention, DMY pins day-month-year, MDY pins month-day-year, YMD pins
-- ISO yyyy-MM-dd. Display-time only — stored instants stay UTC.
--
-- Additive + non-destructive: a new NOT NULL column with a default, no
-- backfill needed. Existing rows read AUTO. Guarded so a re-run is a
-- no-op (the enum create + the column add both skip when present).
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type WHERE typname = 'date_format_preference'
) THEN
CREATE TYPE "date_format_preference" AS ENUM ('AUTO', 'DMY', 'MDY', 'YMD');
END IF;
END
$$;
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "date_format" "date_format_preference" NOT NULL DEFAULT 'AUTO';
@@ -0,0 +1,148 @@
-- v1.21.0 — rebase the regression accumulators to a fixed recent x-origin.
--
-- Migration 0190 stored the OLS accumulators `sum_x / sum_xy / sum_xx` over a
-- RAW epoch-day x-axis (`EXTRACT(EPOCH FROM measured_at) / 86400.0`). Raw
-- epoch-days sit at x ≈ 20 540, so `x²` lands near 4.2e8 and the per-bucket
-- `SUM(x²)` accumulates past ~1e10. A double carries ~15-16 significant decimal
-- digits; a value ~1e10 has ~10 integer digits, leaving only ~5-6 fractional
-- digits — so the SUB-DAY x detail (the time-of-day fraction) is already lost
-- when `sum_xx` is squared and summed, BEFORE the value is ever stored. No
-- read-side identity (mean-centering included) can recover bits lost at
-- accumulation time. That precision floor is why the windowed slope / r² on a
-- near-flat, ill-conditioned window drifted from the live REGR_* probe past the
-- 1e-9 parity gauge (the rollup-regression-parity DST case).
--
-- The fix is a WINDOW-LOCAL X-RESCALE: accumulate x RELATIVE to a fixed origin
-- so the squared terms stay small and exact. The origin is 2020-01-01
-- (epoch-day 18262); rebased x = epoch_days − 18262 stays in the low thousands
-- for any realistic reading, so `x²` ≤ ~1e7 and `sum_xx` never sheds precision.
--
-- sum_x' = Σ (epoch_days − 18262)
-- sum_xy' = Σ (epoch_days − 18262) · value
-- sum_xx' = Σ (epoch_days − 18262)²
--
-- Slope / r² / population-sd are INVARIANT under an affine x-shift (Sxx, Sxy,
-- Syy are unchanged when a constant is subtracted from every x), so the
-- cross-bucket compose yields the SAME regression as before — only the
-- unreported intercept would move. The live REGR_* probe stays on raw
-- epoch-days and still parity-matches because it, too, is shift-invariant.
-- `sum_yy / sum_value / count / mean / sd / slope / r2` are y-only or
-- shift-invariant and are NOT touched.
--
-- This recomputes the three rebased accumulators DIRECTLY FROM `measurements`,
-- grouped exactly as the writer groups (per user / type / granularity-bucket /
-- source over non-deleted rows). Recompute-from-source makes the migration
-- naturally IDEMPOTENT and self-correcting: a rerun re-derives the same rebased
-- sums regardless of the column's prior state (raw 0190 basis, partially
-- rebased, or already rebased). It only writes rows that already carry the 0190
-- accumulators (`sum_xy IS NOT NULL`), so NULL pre-migration rows stay NULL for
-- the boot re-fold to fill on the new basis. Set-based, one statement per
-- granularity; the `(user_id, type, measured_at)` index bounds the scan.
--
-- Reversibility (down): re-fold the accumulators on the raw epoch-day basis,
-- i.e. drop the `− 18262` from each SUM. There is no column shape change to
-- revert; the legacy basis is recoverable by re-running 0190's populator.
-- DAY buckets.
UPDATE "measurement_rollups" r
SET "sum_x" = s."sum_x",
"sum_xy" = s."sum_xy",
"sum_xx" = s."sum_xx"
FROM (
SELECT
m."user_id" AS user_id,
m."type" AS type,
date_trunc('day', m."measured_at") AS bucket_start,
m."source" AS source,
SUM(EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262) AS sum_x,
SUM((EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262) * m."value") AS sum_xy,
SUM((EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262)
* (EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262)) AS sum_xx
FROM measurements m
WHERE m."deleted_at" IS NULL
GROUP BY m."user_id", m."type", date_trunc('day', m."measured_at"), m."source"
) s
WHERE r."granularity" = 'DAY'
AND r."sum_xy" IS NOT NULL
AND r."user_id" = s.user_id
AND r."type" = s.type
AND r."bucket_start" = s.bucket_start
AND r."source" = s.source;
-- WEEK buckets.
UPDATE "measurement_rollups" r
SET "sum_x" = s."sum_x",
"sum_xy" = s."sum_xy",
"sum_xx" = s."sum_xx"
FROM (
SELECT
m."user_id" AS user_id,
m."type" AS type,
date_trunc('week', m."measured_at") AS bucket_start,
m."source" AS source,
SUM(EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262) AS sum_x,
SUM((EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262) * m."value") AS sum_xy,
SUM((EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262)
* (EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262)) AS sum_xx
FROM measurements m
WHERE m."deleted_at" IS NULL
GROUP BY m."user_id", m."type", date_trunc('week', m."measured_at"), m."source"
) s
WHERE r."granularity" = 'WEEK'
AND r."sum_xy" IS NOT NULL
AND r."user_id" = s.user_id
AND r."type" = s.type
AND r."bucket_start" = s.bucket_start
AND r."source" = s.source;
-- MONTH buckets.
UPDATE "measurement_rollups" r
SET "sum_x" = s."sum_x",
"sum_xy" = s."sum_xy",
"sum_xx" = s."sum_xx"
FROM (
SELECT
m."user_id" AS user_id,
m."type" AS type,
date_trunc('month', m."measured_at") AS bucket_start,
m."source" AS source,
SUM(EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262) AS sum_x,
SUM((EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262) * m."value") AS sum_xy,
SUM((EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262)
* (EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262)) AS sum_xx
FROM measurements m
WHERE m."deleted_at" IS NULL
GROUP BY m."user_id", m."type", date_trunc('month', m."measured_at"), m."source"
) s
WHERE r."granularity" = 'MONTH'
AND r."sum_xy" IS NOT NULL
AND r."user_id" = s.user_id
AND r."type" = s.type
AND r."bucket_start" = s.bucket_start
AND r."source" = s.source;
-- YEAR buckets.
UPDATE "measurement_rollups" r
SET "sum_x" = s."sum_x",
"sum_xy" = s."sum_xy",
"sum_xx" = s."sum_xx"
FROM (
SELECT
m."user_id" AS user_id,
m."type" AS type,
date_trunc('year', m."measured_at") AS bucket_start,
m."source" AS source,
SUM(EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262) AS sum_x,
SUM((EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262) * m."value") AS sum_xy,
SUM((EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262)
* (EXTRACT(EPOCH FROM m."measured_at") / 86400.0 - 18262)) AS sum_xx
FROM measurements m
WHERE m."deleted_at" IS NULL
GROUP BY m."user_id", m."type", date_trunc('year', m."measured_at"), m."source"
) s
WHERE r."granularity" = 'YEAR'
AND r."sum_xy" IS NOT NULL
AND r."user_id" = s.user_id
AND r."type" = s.type
AND r."bucket_start" = s.bucket_start
AND r."source" = s.source;
+22 -3
View File
@@ -228,6 +228,11 @@ model User {
// (en → AM/PM, de → 24h), H12 forces AM/PM, H24 forces 24-hour.
// Display-time only — canonical storage stays UTC instants.
timeFormat TimeFormatPreference @default(AUTO) @map("time_format")
// Date display preference. AUTO follows the active locale convention
// (de → dd.MM.yyyy, en → MM/dd/yyyy); DMY pins day-month-year,
// MDY pins month-day-year, YMD pins ISO yyyy-MM-dd. Display-time only —
// canonical storage stays UTC instants.
dateFormat DateFormatPreference @default(AUTO) @map("date_format")
// AI provider selection for insights. Null = OPENAI fallback.
aiProvider String? @map("ai_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL" | "CHATGPT_OAUTH"
aiModel String? @map("ai_model")
@@ -973,6 +978,17 @@ enum TimeFormatPreference {
@@map("time_format_preference")
}
// Date display preference for rendered dates. AUTO defers to the active
// locale's convention; DMY/MDY/YMD pin the field order regardless of locale.
enum DateFormatPreference {
AUTO
DMY
MDY
YMD
@@map("date_format_preference")
}
enum MeasurementSource {
MANUAL
WITHINGS
@@ -1297,9 +1313,12 @@ model MeasurementRollup {
/// `n = count` and `Σy = mean·count` these close the additive set so a
/// windowed slope / r² / sd composes from summed accumulators across DAY
/// buckets with a bit-identical result to live REGR_SLOPE / REGR_R2 /
/// STDDEV_POP. The x-axis is epoch days (`EXTRACT(EPOCH FROM
/// measured_at) / 86400.0`), stored un-rebased so the cross-bucket sum
/// reconstructs the live regression. Nullable to keep migration 0190
/// STDDEV_POP. The x-axis is epoch days REBASED to 2020-01-01
/// (`EXTRACT(EPOCH FROM measured_at) / 86400.0 − 18262`; v1.21.0 migration
/// 0193) so `sum_xx` stays O(1e7) instead of O(1e10) and never sheds
/// precision squaring a ~20 540 raw epoch-day. Slope / r² / sd are
/// invariant under the x-shift, so the live (raw-epoch) REGR_* probe still
/// parity-matches the cross-bucket compose. Nullable to keep migration 0190
/// additive — the boot backfill fills NULLs for pre-migration rows; new
/// rows always carry them (they ride the same aggregate as mean / sum).
sumX Float? @map("sum_x")
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.20.2";
/* @sw-version-fallback */ "v1.21.0";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+4
View File
@@ -96,6 +96,10 @@ export const GET = apiHandler(async () => {
// Hour-cycle display preference (AUTO follows the locale convention,
// H12 / H24 pin the cycle). Clients mirror this into their formatters.
timeFormat: user.timeFormat ?? "AUTO",
// Date-order display preference (AUTO follows the locale convention,
// DMY / MDY / YMD pin the field order). Clients mirror this into their
// formatters and the <DateField> primitive.
dateFormat: user.dateFormat ?? "AUTO",
lastReportPracticeName: user.lastReportPracticeName ?? null,
// v1.4.47 W3 — per-user Coach opt-out. Default `false` if the
// column is absent (partial-deploy rollback safety, see migration
@@ -71,9 +71,10 @@ vi.mock("@/lib/ai/coach/types", () => ({
// thin stub keeps the import graph satisfied without pulling the real schemas.
vi.mock("@/lib/ai/coach/tools", () => ({
COACH_TOOL_DEFS: [],
MAX_ROUNDS: 2,
MAX_ROUNDS: 3,
buildCoachDataInventory: vi.fn(),
renderDataInventory: vi.fn(),
renderFocusHint: vi.fn(() => ""),
buildToolModeAddendum: vi.fn(),
runCoachToolLoop: vi.fn(),
}));
@@ -94,6 +95,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(),
enforceBudget: vi.fn(),
recordSpend: vi.fn(),
resolveDailyCap: vi.fn(() => 200_000),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({ detectRefusal: vi.fn() }));
vi.mock("@/lib/ai/coach/system-prompt", () => ({
@@ -100,6 +100,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-06-21"),
reserveBudget: vi.fn(async () => ({ allowed: true, reserved: 1500 })),
reconcileSpend: vi.fn(async () => undefined),
resolveDailyCap: vi.fn(() => 2_000_000),
}));
vi.mock("@/lib/ai/coach/refusal", () => ({
detectRefusal: vi.fn(() => ({ refuse: false })),
@@ -111,6 +111,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-06-21"),
reserveBudget,
reconcileSpend,
resolveDailyCap: vi.fn(() => 2_000_000),
}));
const { detectRefusal } = vi.hoisted(() => ({
@@ -137,30 +138,36 @@ vi.mock("@/lib/ai/coach/snapshot", () => ({
}));
// Tool-module surface — stub the inventory + loop so we assert routing only.
const { buildCoachDataInventory, renderDataInventory, runCoachToolLoop } =
vi.hoisted(() => ({
buildCoachDataInventory: vi.fn(async () => ({
entries: [],
restMode: false,
cycleEnabled: false,
window: "last30days",
})),
renderDataInventory: vi.fn(
() => "DATA INVENTORY\n- blood pressure: present",
),
runCoachToolLoop: vi.fn(async () => ({
result: { content: "Your BP is steady.", tokensUsed: 80, model: "m" },
workingProviderType: "anthropic",
totalTokens: 80,
rounds: 2,
toolTrace: [{ name: "get_metric_series", present: true }],
})),
}));
vi.mock("@/lib/ai/coach/tools", () => ({
COACH_TOOL_DEFS: [{ name: "get_metric_series" }],
MAX_ROUNDS: 2,
const {
buildCoachDataInventory,
renderDataInventory,
renderFocusHint,
runCoachToolLoop,
} = vi.hoisted(() => ({
buildCoachDataInventory: vi.fn(async () => ({
entries: [],
restMode: false,
cycleEnabled: false,
window: "last30days",
probeScope: { sources: ["bp", "hrv"], window: "last30days" },
})),
renderDataInventory: vi.fn(() => "DATA INVENTORY\n- blood pressure: present"),
renderFocusHint: vi.fn(() => ""),
runCoachToolLoop: vi.fn(async () => ({
result: { content: "Your BP is steady.", tokensUsed: 80, model: "m" },
workingProviderType: "anthropic",
totalTokens: 80,
rounds: 2,
toolTrace: [{ name: "get_metric_series", present: true }],
toolResults: [],
})),
}));
vi.mock("@/lib/ai/coach/tools", () => ({
COACH_TOOL_DEFS: [{ name: "get_metric_series" }],
MAX_ROUNDS: 3,
buildCoachDataInventory,
renderDataInventory,
renderFocusHint,
buildToolModeAddendum: vi.fn(() => "TOOL ADDENDUM"),
runCoachToolLoop,
}));
@@ -183,6 +190,9 @@ vi.mock("@/lib/validations/coach-prefs", () => ({
}));
const { appendMessage } = await import("@/lib/ai/coach/persistence");
const { parseKeyValuesSentinel } = await import("@/lib/ai/coach/keyvalues");
const { parseSuggestReminder } =
await import("@/lib/ai/coach/suggest-reminder");
vi.mock("@/lib/sse/create-stream", () => ({
createSseStream: (
@@ -241,8 +251,14 @@ describe("coach chat — tool-mode routing (F1)", () => {
{ providerType: "anthropic", instance: {} },
]);
await post(chatReq({ message: "How is my BP?" }));
// maxTokens (1500) × MAX_ROUNDS (2).
expect(reserveBudget).toHaveBeenCalledWith("u1", 3000, "2026-06-21");
// maxTokens (1500) × MAX_ROUNDS (3). The 4th arg is the provider-aware
// daily cap (F1) — mocked to the user-plan ceiling for this BYOK chain.
expect(reserveBudget).toHaveBeenCalledWith(
"u1",
4500,
"2026-06-21",
2_000_000,
);
});
it("persists the tool trace onto provenance", async () => {
@@ -260,6 +276,50 @@ describe("coach chat — tool-mode routing (F1)", () => {
).toEqual([{ name: "get_metric_series", present: true }]);
});
it("soft-strips a prose number the tools never returned (P6)", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
]);
// Echo the real prose through the sentinel + suggest parsers so the
// verifier sees the model's actual numbers (the default mocks return a
// fixed string).
const drift = "Your systolic averaged about 138 lately.";
(parseKeyValuesSentinel as ReturnType<typeof vi.fn>).mockReturnValue({
prose: drift,
keyValues: [],
malformed: false,
malformedEntries: [],
});
(parseSuggestReminder as ReturnType<typeof vi.fn>).mockReturnValue({
prose: drift,
});
// The tool returned systolic 128; the prose drifts to 138.
(runCoachToolLoop as ReturnType<typeof vi.fn>).mockImplementation(
async () => ({
result: {
content: "Your systolic averaged about 138 lately.",
tokensUsed: 80,
model: "m",
},
workingProviderType: "anthropic",
totalTokens: 80,
rounds: 2,
toolTrace: [{ name: "get_metric_series", present: true }],
toolResults: [
{ present: true, data: { aggregate: { avgSys30: 128 } } },
],
}),
);
await post(chatReq({ message: "How is my BP?" }));
const calls = (appendMessage as ReturnType<typeof vi.fn>).mock.calls;
const assistantCall = calls.find(
(c) => (c[0] as { role: string }).role === "assistant",
);
const content = (assistantCall?.[0] as { content: string }).content;
expect(content).toContain("[unverified]");
expect(content).not.toContain("138");
});
it("falls back to the snapshot-stuffing path when a provider lacks tools", async () => {
resolveProviderChain.mockResolvedValue([
{ providerType: "anthropic", instance: {} },
@@ -273,8 +333,14 @@ describe("coach chat — tool-mode routing (F1)", () => {
expect(runCoachToolLoop).not.toHaveBeenCalled();
expect(runRawCompletionWithFallback).toHaveBeenCalledTimes(1);
// Single-round budget reservation in the fallback path.
expect(reserveBudget).toHaveBeenCalledWith("u1", 1500, "2026-06-21");
// Single-round budget reservation in the fallback path; the 4th arg is the
// provider-aware daily cap (F1).
expect(reserveBudget).toHaveBeenCalledWith(
"u1",
1500,
"2026-06-21",
2_000_000,
);
// The legacy path ships the snapshot figures in the user turn.
const params = (
(runRawCompletionWithFallback.mock.calls[0] as unknown[])[0] as {
+91 -1
View File
@@ -64,6 +64,7 @@ import {
buildDateKey,
reserveBudget,
reconcileSpend,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { detectRefusal } from "@/lib/ai/coach/refusal";
import {
@@ -77,6 +78,7 @@ import {
COACH_TOOL_DEFS,
buildCoachDataInventory,
renderDataInventory,
renderFocusHint,
buildToolModeAddendum,
runCoachToolLoop,
MAX_ROUNDS,
@@ -84,6 +86,11 @@ import {
} from "@/lib/ai/coach/tools";
import type { AiMessage } from "@/lib/ai/types";
import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues";
import {
findUnverifiedCoachNumbers,
stripUnverifiedNumbers,
} from "@/lib/ai/coach/coach-prose-grounding";
import { scrubUnknownLearnLinks } from "@/lib/ai/coach/learn-link-guard";
import { parseSuggestReminder } from "@/lib/ai/coach/suggest-reminder";
import { gateSuggestion } from "@/lib/ai/coach/suggest-gate";
import {
@@ -499,6 +506,12 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
// so reserve the per-call ceiling × the round count up front and reconcile
// the SUMMED actual tokens afterwards. The atomic reserve/reconcile
// primitives are unchanged; only the reserved amount scales.
// v1.21.0 (F1) — the daily ceiling is the OPERATOR's cost cap only when the
// chain egresses via the operator's own key (`admin-openai` primary). A
// ChatGPT-OAuth/Codex or BYOK chain runs on the user's OWN plan/key and costs
// the operator nothing, so it gets the generous user-plan ceiling — gating it
// on the operator-cost cap would lock the user out of a plan they pay for.
const dailyCap = resolveDailyCap(chain);
const reqDateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
@@ -506,6 +519,7 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
? AI_BUDGETS.coach.maxTokens * MAX_ROUNDS
: AI_BUDGETS.coach.maxTokens,
reqDateKey,
dailyCap,
);
if (!reservation.allowed) {
annotate({
@@ -518,7 +532,13 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
let result: CompletionResult;
let workingProviderType: string;
let toolTrace: CoachToolTrace[] = [];
// v1.21.0 (P6) — the present tool-result payloads this turn, for the post-hoc
// prose number-verifier. Empty on the no-tools path.
let toolResultPayloads: unknown[] = [];
let totalTokensSpent: number;
// v1.21.0 (F3) — cached-input tokens to subtract at reconcile (prompt-cached
// input the user did not re-pay for must not be billed to the daily meter).
let cachedTokensSpent = 0;
try {
if (toolMode) {
// v1.20.0 (F1) — base context: the full system prompt + a tool-mode
@@ -529,10 +549,17 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
// that fire this turn share its reads.
const inventory = await buildCoachDataInventory(userId, effectiveScope);
const toolSystem = `${systemPrompt}\n\n${buildToolModeAddendum(locale)}`;
// v1.21.0 (D1) — when the Coach was opened from a metric page/card, thread
// the launch sources into a one-line FOCUS hint so tool mode honours the
// metric the user is looking at (the no-tools path already narrows the
// snapshot; the inventory probes the full set, so this is the tool-mode
// equivalent of that narrowing). Empty string on a generic open.
const focusHint = renderFocusHint(effectiveScope?.sources);
const focusBlock = focusHint ? `${focusHint}\n\n` : "";
const messages: AiMessage[] = [
{
role: "user",
content: `${renderDataInventory(inventory)}${guidedBlock}
content: `${focusBlock}${renderDataInventory(inventory)}${guidedBlock}
CONVERSATION
${transcript}
@@ -549,6 +576,11 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch
temperature: AI_BUDGETS.coach.temperature,
maxTokens: AI_BUDGETS.coach.maxTokens,
fallbackWindow: effectiveScope?.window,
// v1.21.0 (D5-1) — share the inventory's full-source snapshot across
// every tool so the turn builds ONE snapshot, not one per tool. The
// probe scope is the exact scope the inventory was built against, so the
// per-tool reads land its 60s LRU entry.
sharedScope: inventory.probeScope,
// v1.20.1 — thread the abort signal so a mid-generation disconnect tears
// down the per-round provider calls instead of paying the full cost.
signal: request.signal,
@@ -556,7 +588,9 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch
result = loop.result;
workingProviderType = loop.workingProviderType;
toolTrace = loop.toolTrace;
toolResultPayloads = (loop.toolResults ?? []).map((r) => r.data);
totalTokensSpent = loop.totalTokens;
cachedTokensSpent = loop.cachedTokens;
} else {
const fallback = await runRawCompletionWithFallback({
userId,
@@ -579,6 +613,7 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch
result = fallback.result;
workingProviderType = fallback.workingProvider.providerType;
totalTokensSpent = result.tokensUsed ?? 0;
cachedTokensSpent = result.cachedInputTokens ?? 0;
}
} catch (err) {
// The provider chain failed outright — no tokens were billed, so refund
@@ -635,6 +670,7 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch
reservation.reserved,
totalTokensSpent,
reqDateKey,
cachedTokensSpent,
).catch(() => {
// Ledger reconcile is best-effort; a failure leaves the conservative
// reservation in place (never an undercount) and never breaks the turn.
@@ -701,6 +737,60 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch
});
}
// v1.21.0 (P6 / C2-5) — post-hoc numeric verifier on the Coach prose. On the
// tool path, cross-check every number the model cited against the figures the
// tools actually returned this turn; an unmatched number (transcription /
// paraphrase drift) is soft-stripped to "[unverified]" and annotated. Cheap,
// non-blocking, and a no-op when no tool returned figures (a qualitative turn
// or the no-tools path) — the prompt-level grounding rule remains the
// backstop there, exactly like the briefing's "no signals → skip". A blocked
// turn already carries canned fallback prose, so skip it.
if (!outbound.block && toolResultPayloads.length > 0) {
const unverified = findUnverifiedCoachNumbers(
replyText,
toolResultPayloads,
);
if (unverified.length > 0) {
const { prose: corrected, stripped } = stripUnverifiedNumbers(
replyText,
unverified,
);
replyText = corrected;
annotate({
action: { name: "coach.prose.number_unverified" },
meta: {
flagged: unverified.length,
stripped,
// No raw values — just the count + truncated tokens for ops triage.
tokens: unverified.slice(0, 6).map((u) => u.source),
promptVersion: PROMPT_VERSION,
},
});
}
}
// v1.21.0 (NEW-C C-3) — Learn-link post-filter. The prompt instructs the
// model to only link a published `/learn/<slug>`, but that is guidance, not
// enforcement: a fabricated `/learn/<invented-slug>` would otherwise ship as
// a dead link. Scrub any reference whose slug is not in the catalog (a real
// one is kept verbatim). A blocked turn carries canned fallback prose with no
// links, so skip it.
if (!outbound.block && replyText.includes("/learn/")) {
const scrubbed = scrubUnknownLearnLinks(replyText);
if (scrubbed.dropped.length > 0) {
replyText = scrubbed.text;
annotate({
action: { name: "coach.learn.link_dropped" },
meta: {
dropped: scrubbed.dropped.length,
// Truncated slug tokens for ops triage — no user content.
slugs: scrubbed.dropped.slice(0, 6),
promptVersion: PROMPT_VERSION,
},
});
}
}
let surfacedSuggestion: CoachSuggestion | null = null;
if (!outbound.block && suggestParse.cadence) {
const decision = await gateSuggestion({
@@ -10,6 +10,14 @@ vi.mock("@/lib/db", () => ({
},
measurement: { findMany: vi.fn().mockResolvedValue([]) },
moodEntry: { findMany: vi.fn().mockResolvedValue([]) },
// FDREXTEND — the route now folds two non-measurement channels in (built by
// the shared `correlation-channel-series` helpers). Default to empty corpora
// so neither channel produces a point and the discovery stays trivially
// pair-less for the existing assertions.
medication: { findMany: vi.fn().mockResolvedValue([]) },
medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) },
illnessEpisode: { findMany: vi.fn().mockResolvedValue([]) },
illnessDayLog: { findMany: vi.fn().mockResolvedValue([]) },
},
}));
@@ -87,6 +95,20 @@ beforeEach(() => {
[],
);
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
// FDREXTEND — the two non-measurement channels read their own models; default
// them empty so neither channel produces a point.
(prisma.medication.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
[],
);
(
prisma.medicationIntakeEvent.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([]);
(
prisma.illnessEpisode.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([]);
(prisma.illnessDayLog.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
[],
);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
+45 -18
View File
@@ -26,11 +26,18 @@ import { wallClockInTz } from "@/lib/tz/wall-clock";
import type { MeasurementType } from "@/generated/prisma/client";
import {
discoverCorrelations,
discoveryMeasurementTypes,
DISCOVERY_BEHAVIOURS,
DISCOVERY_OUTCOMES,
MEDICATION_COMPLIANCE_CHANNEL_KEY,
SYMPTOM_SEVERITY_CHANNEL_KEY,
type DailySeriesPoint,
type NamedSeries,
} from "@/lib/insights/correlation-discovery";
import {
fetchComplianceSeries,
fetchSymptomSeries,
} from "@/lib/insights/correlation-channel-series";
export const dynamic = "force-dynamic";
@@ -85,16 +92,19 @@ export const GET = apiHandler(async () => {
select: { timezone: true },
});
const tz = profile?.timezone ?? "Europe/Berlin";
const since = new Date(Date.now() - WINDOW_DAYS * MS_PER_DAY);
const now = new Date();
const since = new Date(now.getTime() - WINDOW_DAYS * MS_PER_DAY);
// MOOD is backed by mood entries, not measurements — it appears as both a
// behaviour and (v1.11.5 F3) an outcome channel, so filter it out of the
// measurement type list on either side.
const behaviourTypes = DISCOVERY_BEHAVIOURS.filter(
(k) => k !== "MOOD",
// Non-MeasurementType channels are backed by other models, not measurements —
// MOOD (MoodEntry), MEDICATION_COMPLIANCE (the dose-history ledger), and
// SYMPTOM_SEVERITY (the illness day-log). `discoveryMeasurementTypes` drops
// them so the `type IN (...)` query carries only real enum values; each is
// built from its own source below and folded into the series.
const behaviourTypes = discoveryMeasurementTypes(
DISCOVERY_BEHAVIOURS,
) as MeasurementType[];
const outcomeTypes = DISCOVERY_OUTCOMES.filter(
(k) => k !== "MOOD",
const outcomeTypes = discoveryMeasurementTypes(
DISCOVERY_OUTCOMES,
) as MeasurementType[];
const [measurements, moodEntries] = await Promise.all([
@@ -134,20 +144,33 @@ export const GET = apiHandler(async () => {
tz,
);
// v1.21.0 (FDREXTEND) — build the two non-measurement, non-mood channels from
// their own sources. Each degrades to an empty series when the user has no
// data, so the discovery loop drops the channel (it cannot clear n ≥ 20).
const complianceSeries = await fetchComplianceSeries(user.id, tz, since);
const symptomSeries = await fetchSymptomSeries(user.id, tz, since);
const points = (key: string): DailySeriesPoint[] =>
key === "MOOD"
? moodDaily
: toDailyMeans(measurementsByType.get(key) ?? [], tz);
const series: NamedSeries[] = [];
for (const key of DISCOVERY_BEHAVIOURS) {
const points =
key === "MOOD"
? moodDaily
: toDailyMeans(measurementsByType.get(key) ?? [], tz);
series.push({ key, role: "behaviour", points });
if (key === MEDICATION_COMPLIANCE_CHANNEL_KEY) {
series.push(complianceSeries);
} else if (key === SYMPTOM_SEVERITY_CHANNEL_KEY) {
series.push({ ...symptomSeries, role: "behaviour" });
} else {
series.push({ key, role: "behaviour", points: points(key) });
}
}
for (const key of DISCOVERY_OUTCOMES) {
const points =
key === "MOOD"
? moodDaily
: toDailyMeans(measurementsByType.get(key) ?? [], tz);
series.push({ key, role: "outcome", points });
if (key === SYMPTOM_SEVERITY_CHANNEL_KEY) {
series.push({ ...symptomSeries, role: "outcome" });
} else {
series.push({ key, role: "outcome", points: points(key) });
}
}
const result = discoverCorrelations(series);
@@ -158,6 +181,10 @@ export const GET = apiHandler(async () => {
pairs_tested: result.pairsTested,
discovered: result.discovered.length,
fdr_q: result.fdrQ,
// FDREXTEND — per-channel day-counts so a dashboard can see whether the
// two sparse new channels reached the n ≥ 20 floor or degraded to absent.
compliance_days: complianceSeries.points.length,
symptom_days: symptomSeries.points.length,
},
});
@@ -41,6 +41,7 @@ vi.mock("@/lib/ai/coach/budget", () => ({
buildDateKey: vi.fn(() => "2026-06-26"),
reserveBudget: vi.fn(),
reconcileSpend: vi.fn().mockResolvedValue(undefined),
resolveDailyCap: vi.fn(() => 200_000),
}));
vi.mock("@/lib/labs/ocr-extract", async () => {
const actual = await vi.importActual<typeof import("@/lib/labs/ocr-extract")>(
@@ -106,6 +107,8 @@ describe("POST /api/labs/ocr/extract — text mode budget", () => {
"user-1",
AI_BUDGETS.ocrExtractText.maxTokens,
"2026-06-26",
// F1 — the provider-aware daily cap (mocked) is threaded as the 4th arg.
200_000,
);
});
+9
View File
@@ -35,6 +35,7 @@ import {
buildDateKey,
reconcileSpend,
reserveBudget,
resolveDailyCap,
} from "@/lib/ai/coach/budget";
import { prisma } from "@/lib/db";
import {
@@ -142,11 +143,15 @@ async function handleTextExtract(
// a vision call, so it reserves the proportionate text ceiling rather than
// the vision budget. Over-charging the vision rate for a text call would
// exhaust the day budget against spend that never happened.
// v1.21.0 (F1) — the operator-cost cap applies only when the picked provider
// egresses on the operator's own key; a BYOK / Codex / local pick runs on the
// user's own plan and gets the generous user-plan ceiling.
const dateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
AI_BUDGETS.ocrExtractText.maxTokens,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
);
if (!reservation.allowed) {
annotate({
@@ -234,11 +239,15 @@ async function handleVisionExtract(
}
// 4. Reserve the day's budget BEFORE the provider call (atomic, TOCTOU-safe).
// v1.21.0 (F1) — the operator-cost cap applies only when the picked vision
// provider egresses on the operator's own key; a BYOK / Codex pick runs on
// the user's own plan and gets the generous user-plan ceiling.
const dateKey = buildDateKey();
const reservation = await reserveBudget(
userId,
AI_BUDGETS.ocrExtract.maxTokens,
dateKey,
resolveDailyCap([{ providerType: pick.entry.providerType }]),
);
if (!reservation.allowed) {
annotate({
+3
View File
@@ -42,6 +42,7 @@ export const GET = apiHandler(async () => {
locale: true,
timezone: true,
timeFormat: true,
dateFormat: true,
moodReminderEnabled: true,
fullName: true,
insurerName: true,
@@ -77,6 +78,7 @@ export const GET = apiHandler(async () => {
locale: dbUser?.locale ?? null,
timezone: dbUser?.timezone ?? "Europe/Berlin",
timeFormat: dbUser?.timeFormat ?? "AUTO",
dateFormat: dbUser?.dateFormat ?? "AUTO",
moodReminderEnabled: dbUser?.moodReminderEnabled ?? false,
fullName: dbUser?.fullName ?? null,
insurerName: dbUser?.insurerName ?? null,
@@ -114,6 +116,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
locale: result.user.locale,
timezone: result.user.timezone,
timeFormat: result.user.timeFormat,
dateFormat: result.user.dateFormat,
moodReminderEnabled: result.user.moodReminderEnabled,
fullName: result.user.fullName,
insurerName: result.user.insurerName,
@@ -5,7 +5,7 @@ import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { DateTimeInput } from "@/components/ui/date-input";
import { DateTimeField } from "@/components/ui/date-time-field";
import { Label } from "@/components/ui/label";
import {
Select,
@@ -454,11 +454,11 @@ export function MedicationIntakeQuickAdd({
<Label htmlFor="medication-intake-taken-at">
{t("dashboard.medicationIntakeQuickAdd.timeLabel")}
</Label>
<DateTimeInput
<DateTimeField
id="medication-intake-taken-at"
data-testid="medication-intake-quick-add-taken-at"
value={takenAt}
onChange={(e) => setTakenAt(e.target.value)}
onChange={setTakenAt}
required
aria-required="true"
aria-invalid={!!error || undefined}
+4 -4
View File
@@ -19,6 +19,7 @@ import { Activity, CalendarDays, NotebookPen, Thermometer } from "lucide-react";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
import { Button } from "@/components/ui/button";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
@@ -266,14 +267,13 @@ export function LogDaySheet({
<Label htmlFor="illness-log-date" className="sr-only">
{t("illness.sheet.day")}
</Label>
<Input
<DateField
id="illness-log-date"
type="date"
value={selectedDate}
min={onsetDate}
max={date}
onChange={(e) => {
if (e.target.value) setSelectedDate(e.target.value);
onChange={(value) => {
if (value) setSelectedDate(value);
}}
className="max-w-44"
/>
+3 -3
View File
@@ -10,6 +10,7 @@ import { useState } from "react";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Button } from "@/components/ui/button";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
@@ -230,12 +231,11 @@ export function NewEpisodeSheet({
<div className="space-y-1.5">
<Label htmlFor="illness-onset">{t("illness.new.onset")}</Label>
<Input
<DateField
id="illness-onset"
type="date"
value={onset}
max={today}
onChange={(e) => setOnset(e.target.value)}
onChange={setOnset}
className="max-w-44"
/>
</div>
@@ -1,4 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { createContext } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { RecommendationCard } from "../recommendation-card";
@@ -24,6 +25,10 @@ vi.mock("@tanstack/react-query", () => ({
error: null,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
// v1.21.0 — the card footer's `<AskCoachAction>` reaches the
// query-client mount probe; a null-default context reads as "no client
// mounted" so the Coach hooks return their fail-open defaults.
QueryClientContext: createContext(null),
}));
vi.mock("@/hooks/use-auth", () => ({
@@ -1,4 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { createContext } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { RecommendationCard } from "../recommendation-card";
@@ -66,6 +67,11 @@ vi.mock("@tanstack/react-query", () => ({
error: null,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
// v1.21.0 — the card's footer `<AskCoachAction>` reaches the
// query-client mount probe (`useFeatureFlags` / `useDisableCoach`); the
// probe only needs the context to exist. A null-default context reads
// as "no client mounted" so both hooks return their fail-open defaults.
QueryClientContext: createContext(null),
}));
vi.mock("@/hooks/use-auth", () => ({
@@ -1,4 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { createContext } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
@@ -35,6 +36,10 @@ vi.mock("@tanstack/react-query", () => ({
error: null,
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
// v1.21.0 — the card footer's `<AskCoachAction>` reaches the
// query-client mount probe; a null-default context reads as "no client
// mounted" so the Coach hooks return their fail-open defaults.
QueryClientContext: createContext(null),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
@@ -0,0 +1,91 @@
"use client";
import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import {
useCoachLaunch,
type CoachLaunchScope,
} from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
/**
* v1.21.0 (C4 H2) — discreet "Ask the Coach about this" affordance for
* high-value insight / assessment cards (briefing, recommendation,
* correlation, status, health-score, period narrative).
*
* It is the reverse-direction entry point the C4 audit flagged as
* missing: the cards carry the richest context in the app, so a single
* small action per card opens the Coach pre-scoped to that card's topic
* with a seeded opener. Reuses the existing launch context — no parallel
* launch system.
*
* Subtle by construction: a ghost, muted-foreground text+icon button (no
* alarming colour, no card-tinting — the medication-card rule holds). One
* entry point per card, never a cluster.
*
* Self-gates on the same triple the FAB / inline pill use (provider
* mounted + operator flag + per-user opt-out) so a card never paints a
* dead Coach control.
*/
export interface AskCoachActionProps {
/**
* Composer seed question — a plain-English opener about the card's
* topic. The Coach treats it as composer seed text, not an i18n key
* (the chat route is EN/DE-gated), matching the empty-state convention.
*/
question: string;
/**
* Optional scope so the conversation narrows to the card's source(s).
* Omitted for cards that span the whole picture (e.g. the daily
* briefing), which read better against the default all-source snapshot.
*/
scope?: CoachLaunchScope;
/** Optional visible-label override; defaults to the shared CTA copy. */
label?: string;
/** className passthrough for per-card alignment. */
className?: string;
}
export function AskCoachAction({
question,
scope,
label,
className,
}: AskCoachActionProps) {
const { t } = useTranslations();
const launch = useCoachLaunch();
const flags = useFeatureFlags();
const disableCoach = useDisableCoach();
// Same gate posture as <CoachLaunchButton> / <LayoutCoachFab>: render
// nothing unless the provider is mounted, the operator flag is on, and
// the user has not opted out.
if (!launch) return null;
if (!flags.coach) return null;
if (disableCoach) return null;
const accessibleLabel = label ?? t("insights.coach.askAboutThis");
return (
<Button
type="button"
variant="ghost"
size="sm"
data-slot="ask-coach-action"
onClick={() => launch.askCoach(question, scope)}
className={cn(
// Discreet: muted text, hover lifts to foreground, no fill. Tight
// height so it sits as a quiet footer link, not a primary CTA.
"text-muted-foreground hover:text-foreground h-8 gap-1.5 px-2 text-xs",
className,
)}
>
<Sparkles className="size-3.5" aria-hidden="true" />
<span>{accessibleLabel}</span>
</Button>
);
}
@@ -0,0 +1,167 @@
import type { CoachScopeSource, CoachScopeWindow } from "@/lib/ai/coach/types";
/**
* v1.21.0 (C4 H1/H4) — metric → Coach scope + seed-question map.
*
* The Coach launch context can now carry a live `scope` (a
* `CoachScopeSource` + window) and a `prefill` seed question into the
* conversation. This module is the single place that resolves "the user
* is looking at metric X" into "open the Coach narrowed to source X with
* a data-aware opener."
*
* Two callers consume it:
* - `<SubPageShell>` registers the active metric's scope as the page's
* ambient scope so the global FAB opens contextual to that page
* (no per-metric header icon — CCH-04 stays intact).
* - the "Ask the Coach" affordance on insight/assessment cards passes
* an explicit scope so a card tap lands a pre-scoped conversation.
*
* Seed questions are plain English strings, mirroring the empty-state
* `coachPrefill` convention already in the tree — the Coach chat route
* is English/German-gated and treats the prefill as composer seed text,
* not an i18n key.
*/
export interface CoachMetricScope {
/** Primary source the snapshot narrows to. */
metric: CoachScopeSource;
/** Extra sources to include alongside `metric` (e.g. correlations). */
also?: CoachScopeSource[];
/** Optional day-window override; defaults to the route's `last30days`. */
window?: CoachScopeWindow;
/** Composer seed question — a data-aware opener for the metric. */
question: string;
}
/**
* `SubPageShell` passes the metric's `explainerMetric` token (the key
* feeding `insights.subPage.explainer.<metric>Body`). Map the tokens that
* resolve to a snapshot source to a scope + opener. Tokens absent here
* (mobility / gait micro-metrics with no dedicated snapshot block) simply
* carry no ambient scope — the FAB opens the default snapshot, exactly as
* before, so this is purely additive.
*/
const EXPLAINER_METRIC_SCOPE: Record<string, CoachMetricScope> = {
bloodPressure: {
metric: "bp",
question:
"Walk me through my blood pressure trend over the last 30 days — anything I should keep an eye on?",
},
weight: {
metric: "weight",
question:
"How has my weight been trending lately, and is the direction something to act on?",
},
bmi: {
metric: "bmi",
question: "What does my BMI trend tell me, and how should I read it?",
},
pulse: {
metric: "pulse",
question:
"Walk me through my pulse readings — is anything out of the ordinary?",
},
restingHr: {
metric: "resting_hr",
question:
"How has my resting heart rate been trending, and what does it say about my fitness?",
},
hrv: {
metric: "hrv",
question:
"What is my heart-rate variability telling me about recovery and stress lately?",
},
sleep: {
metric: "sleep",
question:
"Walk me through my recent sleep — duration, consistency, and anything worth changing.",
},
mood: {
metric: "mood",
question: "What patterns do you see in my mood over the last few weeks?",
},
medications: {
metric: "compliance",
question:
"How is my medication adherence looking, and is it lined up with how I've been feeling?",
},
steps: {
metric: "steps",
question:
"How active have I been lately based on my steps, and how does it compare to a healthy baseline?",
},
activeEnergy: {
metric: "active_energy",
question:
"What does my active-energy trend say about my activity level lately?",
},
cardioFitness: {
metric: "vo2_max",
question:
"What does my cardio fitness (VO₂ max) trend mean, and how do I move it in the right direction?",
},
bloodGlucose: {
metric: "glucose",
question:
"Walk me through my recent glucose readings — are they in a healthy range?",
},
oxygenSaturation: {
metric: "spo2",
question: "What does my blood-oxygen (SpO₂) trend tell me?",
},
respiratoryRate: {
metric: "respiratory_rate",
question: "What does my respiratory-rate trend say about my health?",
},
workouts: {
metric: "workouts",
question:
"Walk me through my recent workouts — load, frequency, and how I'm recovering between them.",
},
// Recovery is a synthesis page; anchor on HRV + resting HR + sleep so the
// Coach reads the inputs that drive the recovery read.
recoveryPage: {
metric: "hrv",
also: ["resting_hr", "sleep"],
window: "last7days",
question:
"Why is my recovery where it is right now, and what's driving it?",
},
};
/** Resolve a `SubPageShell` explainer token to a Coach scope, or null. */
export function metricScopeFromExplainer(
explainerMetric: string | undefined,
): CoachMetricScope | null {
if (!explainerMetric) return null;
return EXPLAINER_METRIC_SCOPE[explainerMetric] ?? null;
}
/**
* Resolve a recommendation / correlation `metricSource.type` — the
* model's snapshot-key vocabulary ("bloodPressure", "weight", "pulse",
* "mood", "medications.compliance30", "sleep", "steps", …) — to a single
* `CoachScopeSource`, or null when the key has no snapshot block to
* narrow to. Lets a card's "Ask the Coach" affordance pre-scope the
* conversation to the metric the card is about.
*/
export function scopeSourceFromMetricKey(
metricKey: string | undefined,
): CoachScopeSource | null {
if (!metricKey) return null;
const lower = metricKey.toLowerCase();
if (lower.startsWith("medications.compliance") || lower === "medication") {
return "compliance";
}
if (lower === "bloodpressure" || lower === "blood_pressure") return "bp";
if (lower === "weight") return "weight";
if (lower === "pulse") return "pulse";
if (lower === "resting_hr" || lower === "restinghr") return "resting_hr";
if (lower === "hrv") return "hrv";
if (lower === "mood") return "mood";
if (lower === "sleep" || lower.startsWith("sleep")) return "sleep";
if (lower === "steps" || lower === "activity") return "steps";
if (lower === "bloodglucose" || lower === "blood_glucose") return "glucose";
if (lower === "bmi") return "bmi";
return null;
}
@@ -136,24 +136,30 @@ describe("<CoachConversation> page deep-link (#67)", () => {
expect(html).toContain('data-slot="coach-hero"');
});
// v1.19.1 (C2) — a dedicated, always-visible Conversations button on the
// page surface (not buried in the composer `+` menu).
it("renders the dedicated Conversations button on the page surface", () => {
// v1.21.0 — the page toolbar is a single trailing affordance: the settings
// gear in the top-right corner. The "Conversations" + "New chat" buttons
// were removed from the toolbar (both still live in the composer `+` menu),
// and the settings gear moved here from the composer.
it("renders the settings gear in the top-right page toolbar", () => {
const html = render(<CoachConversation surface="page" />, makeClient());
expect(html).toContain('data-slot="coach-page-conversations"');
expect(html).toContain('data-slot="coach-page-new-chat"');
expect(html).toContain('data-slot="coach-page-settings"');
const gear = html.match(/<a[^>]*data-slot="coach-page-settings"[^>]*>/);
expect(gear?.[0]).toContain('href="/settings/ai"');
// The old toolbar Conversations + New chat buttons are gone.
expect(html).not.toContain('data-slot="coach-page-conversations"');
expect(html).not.toContain('data-slot="coach-page-new-chat"');
});
// v1.19.1 (C5) — entering via the drawer handoff (`?view=conversations`)
// keeps the new-chat hero (no thread auto-resumed) while opening the
// history drawer; the toolbar + button stay present so the pane is never
// a blank dead-end.
it("keeps the hero and toolbar when opened with openHistoryOnMount", () => {
// v1.21.0 — entering via the drawer handoff (`?view=conversations`) keeps
// the new-chat hero (no thread auto-resumed) while opening the history
// drawer; the toolbar gear stays present so the pane is never a blank
// dead-end.
it("keeps the hero and toolbar gear when opened with openHistoryOnMount", () => {
const html = render(
<CoachConversation surface="page" openHistoryOnMount />,
makeClient(),
);
expect(html).toContain('data-slot="coach-hero"');
expect(html).toContain('data-slot="coach-page-conversations"');
expect(html).toContain('data-slot="coach-page-settings"');
});
});
@@ -17,8 +17,10 @@ describe("<CoachHero>", () => {
<CoachHero composer={<div data-slot="test-composer">composer</div>} />,
);
expect(html).toContain('data-slot="coach-hero"');
// Greeting copy from insights.coach.heroGreeting.
expect(html).toContain("How can I help you?");
// Greeting copy from insights.coach.heroGreeting — one line now.
expect(html).toContain("Ask me anything about your data");
// The earlier two-line subline was dropped.
expect(html).not.toContain("Ask about your trends, medications");
// The composer is re-parented into the hero, not forked.
expect(html).toContain('data-slot="coach-hero-composer"');
expect(html).toContain('data-slot="test-composer"');
@@ -26,7 +28,7 @@ describe("<CoachHero>", () => {
it("renders the German greeting under the de locale", () => {
const html = render(<CoachHero composer={null} />, "de");
expect(html).toContain("Wie kann ich dir helfen?");
expect(html).toContain("Frage mich etwas zu deinen Daten");
});
it("does not render starter-question suggestion chips", () => {
@@ -237,10 +237,11 @@ describe("<CoachInput>", () => {
expect(html).toContain('data-slot="coach-input-send"');
});
it("renders the control-hub action row with showHub (page composer)", () => {
// v1.18.11 (W11) — the page composer is the control hub: a `+` actions
// menu (new chat + open conversations) and a settings deep-link sit on
// the action row alongside the mic + send.
it("renders the one-row page composer with showHub", () => {
// v1.21.0 — the page composer is ONE baseline row: a leading `+` actions
// menu (new chat + open conversations), the textarea, then mic + send.
// The settings gear moved OUT of the composer (now the page toolbar's
// top-right), so the composer's front is only the `+`.
const html = render(
<CoachInput
value=""
@@ -253,14 +254,18 @@ describe("<CoachInput>", () => {
);
expect(html).toContain('data-slot="coach-input-hub"');
expect(html).toContain('data-slot="coach-input-actions"');
// The settings gear deep-links to Settings → AI (not an in-chat sheet).
const settings = html.match(
/<a[^>]*data-slot="coach-input-settings"[^>]*>/,
);
expect(settings?.[0]).toContain('href="/settings/ai"');
// Mic + send remain present in the hub layout.
// The settings gear is gone from the composer entirely.
expect(html).not.toContain('data-slot="coach-input-settings"');
// Mic + send remain present on the same row.
expect(html).toContain('data-slot="coach-input-mic"');
expect(html).toContain('data-slot="coach-input-send"');
// The leading `+` precedes the textarea; the textarea precedes the send.
const plusIdx = html.indexOf('data-slot="coach-input-actions"');
const textareaIdx = html.indexOf('data-slot="coach-input-textarea"');
const sendIdx = html.indexOf('data-slot="coach-input-send"');
expect(plusIdx).toBeGreaterThan(-1);
expect(plusIdx).toBeLessThan(textareaIdx);
expect(textareaIdx).toBeLessThan(sendIdx);
});
it("sizes the hub actions trigger to the 44px tap-target floor on phones", () => {
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { launchScopeToCoachScope } from "../coach-conversation";
import {
metricScopeFromExplainer,
scopeSourceFromMetricKey,
} from "../../coach-metric-scope";
/**
* v1.21.0 (C4 H1/H4) — the Coach launch scope is now live. These tests pin
* the conversion from the UI launch scope to the chat route's wire
* `CoachScope`, plus the two metric→source resolvers that feed it.
*/
describe("launchScopeToCoachScope", () => {
it("returns undefined for an absent / metric-less scope (default snapshot)", () => {
expect(launchScopeToCoachScope(null)).toBeUndefined();
expect(launchScopeToCoachScope(undefined)).toBeUndefined();
expect(launchScopeToCoachScope({})).toBeUndefined();
});
it("maps a single metric to a one-source scope", () => {
expect(launchScopeToCoachScope({ metric: "bp" })).toEqual({
sources: ["bp"],
});
});
it("includes `also` sources and dedupes against the primary", () => {
expect(
launchScopeToCoachScope({ metric: "bp", also: ["compliance", "bp"] }),
).toEqual({ sources: ["bp", "compliance"] });
});
it("threads the window through only when present", () => {
expect(
launchScopeToCoachScope({ metric: "hrv", window: "last7days" }),
).toEqual({ sources: ["hrv"], window: "last7days" });
expect(launchScopeToCoachScope({ metric: "hrv" })).not.toHaveProperty(
"window",
);
});
});
describe("metricScopeFromExplainer", () => {
it("resolves a mapped explainer token to a scope + opener", () => {
const resolved = metricScopeFromExplainer("bloodPressure");
expect(resolved?.metric).toBe("bp");
expect(resolved?.question).toMatch(/blood pressure/i);
});
it("anchors the recovery page on its driver sources + short window", () => {
const resolved = metricScopeFromExplainer("recoveryPage");
expect(resolved?.metric).toBe("hrv");
expect(resolved?.also).toEqual(["resting_hr", "sleep"]);
expect(resolved?.window).toBe("last7days");
});
it("returns null for an unmapped / undefined token", () => {
expect(metricScopeFromExplainer("walkingAsymmetry")).toBeNull();
expect(metricScopeFromExplainer(undefined)).toBeNull();
});
});
describe("scopeSourceFromMetricKey", () => {
it("maps the model's snapshot-key vocabulary to a scope source", () => {
expect(scopeSourceFromMetricKey("bloodPressure")).toBe("bp");
expect(scopeSourceFromMetricKey("medications.compliance30")).toBe(
"compliance",
);
expect(scopeSourceFromMetricKey("WEIGHT")).toBe("weight");
});
it("returns null for an unknown / absent key", () => {
expect(scopeSourceFromMetricKey("vascular_age")).toBeNull();
expect(scopeSourceFromMetricKey(undefined)).toBeNull();
});
});
@@ -618,7 +618,9 @@ describe("<MessageThread>", () => {
})}
/>,
);
expect(html).toContain("Daily limit reached; resets at 00:00 UTC.");
expect(html).toContain(
"Daily limit reached. The budget refreshes at midnight UTC.",
);
// v1.4.33 IW7 — fallback copy rewritten ("AI provider" -> "Insights
// provider"). Both old and new strings should be absent here when
// the budget-exceeded error takes precedence.
@@ -3,7 +3,7 @@
import { useEffect, useReducer, useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { MessagesSquare, Plus, Settings, Sparkles } from "lucide-react";
import { Plus, Settings, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -17,6 +17,8 @@ import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import type { CoachScope } from "@/lib/ai/coach/types";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import { CoachDrawerBody } from "./coach-drawer-body";
import { CoachHero } from "./coach-hero";
@@ -64,6 +66,27 @@ import {
* `<SheetDescription>` (required for the dialog's accessible name) while
* the page mounts a plain `<h1>` / `<p>`.
*/
/**
* v1.21.0 (C4 H1/H4) — collapse a UI launch scope ({ metric, also,
* window }) into the chat route's wire scope ({ sources, window }).
* Returns undefined when there is nothing to narrow, so the request
* falls back to the route's default all-source snapshot. Exported for
* the unit test that pins the source-dedup + window contract.
*/
export function launchScopeToCoachScope(
launchScope: CoachLaunchScope | null | undefined,
): CoachScope | undefined {
if (!launchScope?.metric) return undefined;
const sources = Array.from(
new Set([launchScope.metric, ...(launchScope.also ?? [])]),
);
return {
sources,
...(launchScope.window ? { window: launchScope.window } : {}),
};
}
export interface CoachConversationProps {
/**
* Pre-fill for the composer (suggested-prompt chip click). Resets the
@@ -71,6 +94,15 @@ export interface CoachConversationProps {
* freely between prop changes.
*/
prefill?: string | null;
/**
* v1.21.0 (C4 H1/H4) — optional launch scope so a conversation opened
* from a metric surface or insight card narrows its snapshot to the
* relevant source(s) + window. Converted to the chat route's
* `CoachScope` and attached to the FIRST turn of a fresh conversation
* (`currentConversationId === null`); a continued thread keeps its own
* established scope. Null → the route's default all-source snapshot.
*/
launchScope?: CoachLaunchScope | null;
/**
* Renders the conversation title. The surface passes the resolved
* title string; the drawer wraps it in `<SheetTitle>`, the page in an
@@ -148,6 +180,7 @@ export interface CoachConversationProps {
export function CoachConversation({
prefill,
launchScope,
renderTitle,
renderDescription,
leadingHeaderActions,
@@ -277,12 +310,21 @@ export function CoachConversation({
dismissQuestions.mutate(guidedQuestion);
}
setInputValue("");
// v1.21.0 (C4 H1/H4) — attach the launch scope to the FIRST turn of a
// fresh conversation so a chat opened from a metric surface / insight
// card reads a snapshot narrowed to the relevant source(s). A continued
// thread (existing id) keeps its own established scope, so we omit it.
const scope =
currentConversationId === null
? launchScopeToCoachScope(launchScope)
: undefined;
// v1.16.6 — hand the question to the turn so the Coach reaction is
// contextual (the question bubble itself is never persisted).
const resolvedId = await send.send({
conversationId: currentConversationId ?? undefined,
message: trimmed,
guidedQuestion: guidedQuestion ?? undefined,
scope,
});
if (guidedQuestion !== null && guidedIndex !== null) {
setPendingAdopt({
@@ -497,35 +539,30 @@ export function CoachConversation({
data-variant={surface}
className={cn("flex min-h-0 flex-1 flex-col", className)}
>
{/* v1.19.1 (C2) — a clear, always-visible "Conversations" button on
the page surface. The composer's `+` menu still carries the same
action, but the maintainer wanted an obvious, dedicated affordance
to reach past conversations rather than one buried in a menu. */}
{/* v1.21.0 — the page toolbar is now a single trailing affordance:
the settings gear in the top-right corner. The "Conversations"
and "New chat" controls were removed here — both still live in the
composer's `+` actions menu, keeping the new-chat surface calm and
uncluttered. The gear deep-links to Settings → AI (one place for
model + behaviour), matching the drawer header's gear. */}
<div
data-slot="coach-page-toolbar"
className="flex shrink-0 items-center justify-between gap-2 px-4 pt-2 sm:px-6"
className="flex shrink-0 items-center justify-end px-4 pt-2 sm:px-6"
>
<Button
type="button"
asChild
variant="ghost"
size="sm"
onClick={() => setHistoryDrawerOpen(true)}
data-slot="coach-page-conversations"
className="text-muted-foreground hover:text-foreground -ml-1 gap-1.5"
size="icon"
data-slot="coach-page-settings"
className="text-muted-foreground hover:text-foreground -mr-1 size-9 shrink-0"
>
<MessagesSquare className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleNewChat}
data-slot="coach-page-new-chat"
className="text-muted-foreground hover:text-foreground -mr-1 gap-1.5"
>
<Plus className="size-4" aria-hidden="true" />
{t("insights.coach.newChat")}
<Link
href="/settings/ai"
aria-label={t("insights.coach.settingsAriaLabel")}
title={t("insights.coach.settingsAriaLabel")}
>
<Settings className="size-4" aria-hidden="true" />
</Link>
</Button>
</div>
{heroActive ? (
@@ -12,6 +12,7 @@ import {
SheetDescription,
SheetTitle,
} from "@/components/ui/sheet";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useIsMobile } from "@/hooks/use-is-mobile";
@@ -47,9 +48,20 @@ export interface CoachDrawerProps {
onOpenChange: (next: boolean) => void;
/** Optional pre-fill for the input box (suggested-prompt chip click). */
prefill?: string | null;
/**
* v1.21.0 (C4 H1/H4) — optional launch scope so a conversation opened
* from a metric surface or insight card is pre-narrowed to the relevant
* source(s) + window. Null → the route's default all-source snapshot.
*/
scope?: CoachLaunchScope | null;
}
export function CoachDrawer({ open, onOpenChange, prefill }: CoachDrawerProps) {
export function CoachDrawer({
open,
onOpenChange,
prefill,
scope,
}: CoachDrawerProps) {
const { t } = useTranslations();
const router = useRouter();
// v1.4.27 R3d MB1 — below the `sm` breakpoint (640 px) the Coach
@@ -126,6 +138,7 @@ export function CoachDrawer({ open, onOpenChange, prefill }: CoachDrawerProps) {
<CoachConversation
surface="drawer"
prefill={prefill}
launchScope={scope}
autoFocusComposer
registerReset={registerReset}
// v1.16.1 — the "Conversations" affordance hands off to the
@@ -64,11 +64,13 @@ export function CoachHero({ composer }: CoachHeroProps) {
<Sparkles className="text-background size-6" />
</div>
{/* Greeting — a slightly larger, tighter display treatment of the
existing font (weight/tracking/size only, no new font). */}
{/* Greeting — one line. A slightly larger, tighter display
treatment of the existing font (weight/tracking/size only, no
new font). The earlier two-line subline was dropped: the
new-chat surface reads as a single calm invitation. */}
<div
className={cn(
"flex flex-col gap-1.5",
"flex flex-col",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-2 motion-safe:duration-500",
)}
style={{ animationDelay: "80ms", animationFillMode: "both" }}
@@ -76,9 +78,6 @@ export function CoachHero({ composer }: CoachHeroProps) {
<h1 className="text-foreground text-2xl font-semibold tracking-tight sm:text-3xl">
{t("insights.coach.heroGreeting")}
</h1>
<p className="text-muted-foreground text-sm">
{t("insights.coach.heroSubline")}
</p>
</div>
{/* Composer — the live <CoachInput>, centred. */}
@@ -9,16 +9,7 @@ import {
useState,
useSyncExternalStore,
} from "react";
import Link from "next/link";
import {
Loader2,
MessagesSquare,
Mic,
Plus,
Send,
Settings,
Square,
} from "lucide-react";
import { Loader2, MessagesSquare, Mic, Plus, Send, Square } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -452,6 +443,46 @@ export function CoachInput({
</Button>
);
// v1.21.0 — the leading `+` actions menu (page surface only). Opens the
// attachment/scope menu: new chat + open conversations. The settings gear
// no longer lives here — it moved to the page toolbar's top-right corner,
// so the composer's front is ONLY the `+`. Const-lifted like the mic / send
// so it slots into the single-row composer without forking the layout.
const actionsButton = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
disabled={disabled}
data-slot="coach-input-actions"
aria-label={t("insights.coach.actionsMenu")}
title={t("insights.coach.actionsMenu")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0 rounded-xl sm:size-9"
>
<Plus className="size-5 sm:size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" className="w-52">
<DropdownMenuItem
data-slot="coach-input-action-new-chat"
onSelect={() => onNewChat?.()}
>
<Plus className="size-4" aria-hidden="true" />
{t("insights.coach.newChat")}
</DropdownMenuItem>
<DropdownMenuItem
data-slot="coach-input-action-history"
onSelect={() => onOpenHistory?.()}
>
<MessagesSquare className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
return (
<form
data-slot="coach-input"
@@ -459,26 +490,30 @@ export function CoachInput({
className="flex flex-col"
>
{/* v1.16.1 / v1.18.7 — modern chat-app composer: a single rounded
field. The drawer surface keeps the textarea flanked by the mic
(left) and send / stop (right) on one baseline. The page surface
(`showHub`) stacks the textarea over a control-hub action row so
the composer carries the conversation controls ChatGPT-style.
`items-end` keeps the single-row controls pinned to the input's
last line as it grows. Enter sends, Shift+Enter inserts a newline. */}
field, ONE baseline row. The drawer surface flanks the textarea with
the mic (left) and send / stop (right). The page surface (`showHub`)
leads with a `+` actions menu (new chat + open conversations), then
the textarea, then the mic + send — same row, vertically centred.
The settings gear is NOT in the composer; it lives in the page
toolbar's top-right corner. `items-end` keeps the flanking controls
pinned to the input's last line as it grows. Enter sends,
Shift+Enter inserts a newline. */}
<div
className={cn(
"border-border/60 bg-muted/40 group rounded-2xl border",
"shadow-sm transition-colors",
"focus-within:border-dracula-purple/50 focus-within:ring-dracula-purple/15 focus-within:bg-background focus-within:ring-2",
showHub ? "flex flex-col gap-1 p-2" : "flex items-end gap-1.5 p-1.5",
// Both surfaces share ONE baseline row: the drawer flanks the
// textarea with mic (left) + send (right); the page leads with a
// `+` actions menu, then the textarea, then mic + send. `items-end`
// keeps the flanking controls pinned to the input's last line as it
// grows.
"flex items-end gap-1.5 p-1.5",
)}
>
{/* v1.18.10 (W4) — the mic always renders so it stays discoverable.
When the browser lacks the Web Speech API (or during SSR) it is
DISABLED with an explanatory tooltip rather than vanishing or
sitting as a dead control that does nothing on tap. In the hub
layout the mic moves into the action row below the textarea. */}
{!showHub && micButton}
{/* Leading control. Page surface: the `+` actions menu. Drawer
surface: the dictation mic. */}
{showHub ? actionsButton : micButton}
<textarea
id={inputId}
ref={textareaRef}
@@ -508,9 +543,9 @@ export function CoachInput({
// Desktop shrinks back to `text-sm` for the compact
// composer.
"min-w-0 flex-1 resize-none bg-transparent text-base leading-relaxed outline-none sm:text-sm",
// Centre the single-line state against the send button so
// the placeholder and the icon share a baseline.
showHub ? "px-2 pt-1.5 pb-0.5" : "px-2 py-1.5",
// Centre the single-line state against the flanking controls so
// the placeholder and the icons share a baseline.
"px-2 py-1.5",
"max-h-[9.5rem] overflow-auto",
// v1.18.7 — calm, thin scrollbar inside the composer when
// dictation overruns 6 lines (see also the thread/history
@@ -520,64 +555,15 @@ export function CoachInput({
)}
/>
{showHub ? (
// v1.18.11 — the control-hub action row (page surface). Leading
// `+` actions menu (new chat + open conversations) and a settings
// deep-link on the left; the mic + send / stop on the right —
// all on one baseline, ChatGPT-style.
<div data-slot="coach-input-hub" className="flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
disabled={disabled}
data-slot="coach-input-actions"
aria-label={t("insights.coach.actionsMenu")}
title={t("insights.coach.actionsMenu")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0 rounded-xl sm:size-9"
>
<Plus className="size-5 sm:size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" className="w-52">
<DropdownMenuItem
data-slot="coach-input-action-new-chat"
onSelect={() => onNewChat?.()}
>
<Plus className="size-4" aria-hidden="true" />
{t("insights.coach.newChat")}
</DropdownMenuItem>
<DropdownMenuItem
data-slot="coach-input-action-history"
onSelect={() => onOpenHistory?.()}
>
<MessagesSquare className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* The Coach preferences live in Settings → AI (one place for
model + behaviour); the gear deep-links there. */}
<Button
asChild
size="icon"
variant="ghost"
data-slot="coach-input-settings"
className="text-muted-foreground hover:text-foreground size-11 shrink-0 rounded-xl sm:size-9"
>
<Link
href="/settings/ai"
aria-label={t("insights.coach.settingsAriaLabel")}
title={t("insights.coach.settingsAriaLabel")}
>
<Settings className="size-4" aria-hidden="true" />
</Link>
</Button>
<div className="ml-auto flex items-center gap-1">
{micButton}
{sendButton}
</div>
// v1.21.0 — page surface trailing cluster: mic + send / stop on the
// same baseline as the leading `+` and the textarea. The settings
// gear moved out of the composer entirely (now in the page toolbar).
<div
data-slot="coach-input-hub"
className="flex shrink-0 items-center gap-1.5"
>
{micButton}
{sendButton}
</div>
) : (
sendButton
+53 -17
View File
@@ -10,7 +10,9 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { CorrelationResult } from "@/lib/insights/correlations";
import type { CoachScopeSource } from "@/lib/ai/coach/types";
import { CONFIDENCE_BADGE_CLASS } from "./confidence-badge";
import { AskCoachAction } from "./ask-coach-action";
/**
* v1.4.20 phase B3 — single Correlation card.
@@ -71,10 +73,36 @@ const CONFIDENCE_LABEL_KEY: Record<"low" | "moderate" | "high", string> = {
low: "insights.correlationRow.confidenceLow",
};
// v1.21.0 (C4 H2) — the two sources each correlation pair spans, so the
// Coach hand-off narrows its snapshot to exactly the relationship the
// card describes.
const COACH_SOURCES_BY_KIND: Record<
CorrelationResult["kind"],
CoachScopeSource[]
> = {
"bp-compliance": ["bp", "compliance"],
"mood-pulse": ["mood", "pulse"],
"weight-weekday": ["weight"],
};
const COACH_QUESTION_BY_KIND: Record<CorrelationResult["kind"], string> = {
"bp-compliance":
"How does my medication adherence relate to my blood pressure? Walk me through what this correlation means for me.",
"mood-pulse":
"Is there a real link between my mood and my pulse? Help me read this correlation.",
"weight-weekday":
"Does my weight follow a weekly pattern? What does this day-of-week correlation tell me?",
};
export function CorrelationCard({ result }: CorrelationCardProps) {
const { t } = useTranslations();
const title = t(TITLE_KEY[result.kind]);
const subtitle = t(SUBTITLE_KEY[result.kind]);
const coachSources = COACH_SOURCES_BY_KIND[result.kind];
const coachScope =
coachSources.length > 0
? { metric: coachSources[0], also: coachSources.slice(1) }
: undefined;
return (
<Card
@@ -134,23 +162,31 @@ export function CorrelationCard({ result }: CorrelationCardProps) {
window: t("insights.correlationRow.sourceWindowLast30"),
})}
</p>
<Button
type="button"
size="sm"
// Outline variant so the disabled placeholder reads as
// "this is on the roadmap" rather than "the system is
// broken right now". Flip back to default when the
// 7-day experiment feature ships in v1.5.
variant="outline"
disabled
data-slot="correlation-card-cta"
className="gap-1.5"
title={t("insights.correlationRow.experimentTooltip")}
aria-label={t("insights.correlationRow.experimentTooltip")}
>
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.correlationRow.experimentCta")}</span>
</Button>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
size="sm"
// Outline variant so the disabled placeholder reads as
// "this is on the roadmap" rather than "the system is
// broken right now". Flip back to default when the
// 7-day experiment feature ships in v1.5.
variant="outline"
disabled
data-slot="correlation-card-cta"
className="gap-1.5"
title={t("insights.correlationRow.experimentTooltip")}
aria-label={t("insights.correlationRow.experimentTooltip")}
>
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.correlationRow.experimentCta")}</span>
</Button>
{/* v1.21.0 (C4 H2) — the live Coach hand-off sits beside the
roadmap CTA: it scopes to both sources in the pair. */}
<AskCoachAction
question={COACH_QUESTION_BY_KIND[result.kind]}
scope={coachScope}
/>
</div>
</>
) : (
<EmptyState
@@ -25,6 +25,7 @@ import { EmptyState } from "@/components/ui/empty-state";
import { ListRow } from "@/components/ui/list-row";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
import { AskCoachAction } from "@/components/insights/ask-coach-action";
import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
@@ -457,6 +458,12 @@ export function DailyBriefing({
)}
</div>
)}
{/* v1.21.0 (C4 H2) — hand off to the Coach for the whole
picture. The briefing spans every metric, so no scope: the
default all-source snapshot reads best here. */}
<div className="flex justify-end">
<AskCoachAction question="Walk me through today's briefing — what should I focus on, and is anything worth acting on?" />
</div>
</div>
) : noProvider ? (
// v1.15.20 — no provider configured anywhere: a regenerate CTA
@@ -62,6 +62,12 @@ export interface SparklineDeltaTileProps {
* explainer; the tile just gives it a slot.
*/
provenance?: React.ReactNode;
/**
* Optional quiet footer slot — a discreet pointer (e.g. a `LearnMoreLink`)
* rendered under the framing row. Plain element children; the caller decides
* whether a tile carries one, so the dense grid stays uncluttered.
*/
footer?: React.ReactNode;
className?: string;
}
@@ -77,6 +83,7 @@ export function SparklineDeltaTile({
staleDays = null,
precision = 1,
provenance,
footer,
className,
}: SparklineDeltaTileProps) {
const { t } = useTranslations();
@@ -244,6 +251,12 @@ export function SparklineDeltaTile({
{t("dashboard.staleHint", { count: staleDays })}
</span>
)}
{footer ? (
<div className="mt-2" data-slot="sparkline-delta-tile-footer">
{footer}
</div>
) : null}
</div>
);
}
@@ -14,6 +14,7 @@ import {
import { TileHeader } from "@/components/insights/tile-header";
import { SectionHeading } from "@/components/insights/section-heading";
import { SparklineDeltaTile } from "./sparkline-delta-tile";
import { LearnMoreLink } from "@/components/ui/learn-more-link";
import { CoverageMeter } from "./coverage-meter";
import { ProvenanceExplainer } from "./provenance-explainer";
import { METRIC_PROVENANCE } from "./standards";
@@ -289,6 +290,7 @@ function BaselineTile({
provenance={
<MetricProvenance metric={metric} provenance={data.provenance} />
}
footer={<LearnMoreLink concept={tileId} />}
/>
</div>
);
@@ -6,6 +6,7 @@ import { Activity, Droplet, FlaskConical, TrendingUp } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LearningGate } from "@/components/ui/learning-gate";
import { LearnMoreLink } from "@/components/ui/learn-more-link";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
@@ -117,6 +118,9 @@ export function GlucoseClinicalPanel() {
<div className="mt-4">
<LabsCrossLink />
</div>
<div className="mt-3">
<LearnMoreLink concept="BLOOD_GLUCOSE" />
</div>
</CardContent>
</Card>
);
@@ -237,6 +241,7 @@ export function GlucoseClinicalPanel() {
) : null}
<LabsCrossLink />
<LearnMoreLink concept="BLOOD_GLUCOSE" />
</CardContent>
</Card>
);
@@ -5,6 +5,7 @@ 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";
import { AskCoachAction } from "./ask-coach-action";
/**
* v1.4.20 B5 — Personal Health Score panel.
@@ -616,6 +617,16 @@ export function HealthScoreCard({
{/* 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. */}
{/* v1.21.0 (C4 H2) — the score is a whole-picture composite, so the
hand-off opens the Coach against the default snapshot (no scope)
seeded with the actual number. Mounts at the foot of the card as
a single discreet entry, not a primary CTA. */}
<div className="mt-auto flex justify-end pt-1">
<AskCoachAction
question={`Why is my health score ${score} out of 100, and what would move it the most?`}
/>
</div>
</div>
</div>
);
@@ -11,6 +11,8 @@ import { formatRelativeTime } from "@/lib/i18n/relative-time";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { cn } from "@/lib/utils";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { AskCoachAction } from "@/components/insights/ask-coach-action";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
// ─── Types ────────────────────────────────────────────────
@@ -56,6 +58,15 @@ interface InsightStatusCardProps {
* card is being assembled, not stuck. The client polls until text lands.
*/
preparing?: boolean;
/**
* v1.21.0 (C4 H2) — opt-in "Ask the Coach about this assessment" hand-off.
* When `coachQuestion` is set (the metric-aware caller supplies it), the
* populated card renders a discreet Coach action seeded with that opener
* and narrowed to `coachScope` when known. Callers that omit it keep the
* card exactly as before — additive.
*/
coachQuestion?: string;
coachScope?: CoachLaunchScope;
}
// ─── Main Component ───────────────────────────────────────
@@ -68,6 +79,8 @@ export function InsightStatusCard({
updatedAt,
loading = false,
preparing = false,
coachQuestion,
coachScope,
}: InsightStatusCardProps) {
const { t } = useTranslations();
const flags = useFeatureFlags();
@@ -228,6 +241,14 @@ export function InsightStatusCard({
they roll forward. */}
<StatusBody text={stripChartTokens(text)} />
<LastUpdatedFooter updatedAt={updatedAt} />
{/* v1.21.0 (C4 H2) — discreet hand-off so the user can take the
assessment into a scoped Coach conversation. Only when the
caller supplied an opener. */}
{coachQuestion ? (
<div className="flex justify-end">
<AskCoachAction question={coachQuestion} scope={coachScope} />
</div>
) : null}
</CardContent>
</Card>
);
@@ -52,6 +52,10 @@ export function LayoutCoachMount() {
open={launch.open}
onOpenChange={launch.setOpen}
prefill={launch.prefill}
// v1.21.0 (C4 H1/H4) — carry the launch scope so a conversation
// opened from a metric surface or insight card is pre-narrowed to
// the relevant source(s).
scope={launch.scope}
/>
);
}
@@ -7,6 +7,7 @@ import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { scopeSourceFromMetricKey } from "@/components/insights/coach-metric-scope";
interface MetricStatusCardProps {
/** The registry metric id the generic assessment route keys on. */
@@ -39,6 +40,19 @@ export function MetricStatusCard({
const mounted = useMounted();
const { data: status, isLoading } = useInsightMetricStatus(metric, enabled);
// v1.21.0 (C4 H2) — narrow the Coach hand-off to this metric's snapshot
// source when one exists; the registry id is a `MeasurementType`-style
// token, which `scopeSourceFromMetricKey` resolves for the metrics that
// carry a snapshot block. Unmapped metrics still get a metric-named
// opener, just against the default snapshot.
const coachSource = scopeSourceFromMetricKey(metric);
const coachQuestion = `Walk me through my ${metric
.toLowerCase()
.replace(
/_/g,
" ",
)} assessment — what does it mean and what should I do about it?`;
return (
<InsightStatusCard
title={t("insights.assessmentTitle")}
@@ -46,6 +60,8 @@ export function MetricStatusCard({
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
updatedAt={status?.updatedAt ?? null}
coachQuestion={coachQuestion}
coachScope={coachSource ? { metric: coachSource } : undefined}
// Same hydration gate as `<SlugInsightStatusCard>`: the hook is
// `enabled: isAuthenticated && enabled`, and both inputs can settle
// before a late-hydrating boundary replays its first render — the
@@ -8,6 +8,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/insights/section-heading";
import { AskCoachAction } from "@/components/insights/ask-coach-action";
import {
Popover,
PopoverContent,
@@ -225,6 +226,21 @@ export function PeriodNarrativeCard({
})}
</p>
) : null}
{/* v1.21.0 (C4 H2) — hand the period summary to the Coach. It spans
the whole picture for the window, so no scope: the default
snapshot reads best. */}
{narrative ? (
<div className="flex justify-end">
<AskCoachAction
question={
period === "week"
? "Walk me through how my last week went across my health metrics — what stands out?"
: "Walk me through how my last month went across my health metrics — what stands out?"
}
/>
</div>
) : null}
</div>
</section>
);
@@ -16,6 +16,8 @@ import { importWithRetry } from "@/lib/retry-import";
import { getMedicalReferenceById } from "@/lib/ai/medical-references";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import type { Locale } from "@/lib/i18n/config";
import { AskCoachAction } from "./ask-coach-action";
import { scopeSourceFromMetricKey } from "./coach-metric-scope";
import {
RecommendationFeedback,
type RecommendationFeedbackSeverity,
@@ -349,6 +351,14 @@ export function RecommendationCard({
}
: null;
// v1.21.0 (C4 H2) — reverse-direction Coach hand-off. The opener
// quotes the rec so the conversation lands on this exact suggestion;
// when the rec carries a metric source, scope the snapshot to it.
const coachScopeSource = scopeSourceFromMetricKey(norm.metricSource?.type);
const coachQuestion = `Tell me more about this recommendation: "${stripChartTokens(
norm.text,
)}" — why does it apply to me, and what should I do about it?`;
return (
<div
data-slot="rec-card"
@@ -435,6 +445,15 @@ export function RecommendationCard({
<CitationFootnote referenceId={norm.referenceId} locale={locale} />
</div>
)}
{/* v1.21.0 (C4 H2) — discreet "ask the Coach about this rec" link,
aligned under the rec text (the number gutter is `ml-6`). */}
<div className="mt-1 ml-5">
<AskCoachAction
question={coachQuestion}
scope={coachScopeSource ? { metric: coachScopeSource } : undefined}
/>
</div>
</div>
);
}
@@ -12,6 +12,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { LearningGate } from "@/components/ui/learning-gate";
import { LearnMoreLink } from "@/components/ui/learn-more-link";
/**
* v1.19.2 — calm readout for Oura's daily resilience level.
@@ -129,6 +130,7 @@ export function ResilienceTile() {
{t(TREND_KEYS[trendKey])}
</p>
) : null}
<LearnMoreLink concept="RESILIENCE" />
</CardContent>
</Card>
);
+38 -7
View File
@@ -15,6 +15,8 @@ import {
} from "@/components/ui/tooltip";
import { TargetAdjustButton } from "@/components/insights/target-adjust-button";
import { TargetAdjustProvider } from "@/lib/insights/target-adjust-context";
import { metricScopeFromExplainer } from "@/components/insights/coach-metric-scope";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
@@ -110,9 +112,15 @@ export interface SubPageShellProps {
* 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.
* paints anything for it.
*
* v1.21.0 (C4 H1) — re-purposed without re-adding any control. When set
* (and `explainerMetric` maps to a Coach source), the shell registers
* the page's metric as the launch context's ambient scope while it is
* mounted. The global FAB then opens a conversation pre-scoped to this
* metric with a data-aware seed question — so drilling into BP / weight
* / sleep and tapping the FAB no longer opens a blank chat. Still no
* second header affordance: the FAB stays the single entry point.
*/
coachLaunch?: boolean;
/**
@@ -137,14 +145,15 @@ export function SubPageShell({
focusOnMount = false,
statStrip,
diversityNudge,
// 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.
// v1.21.0 (C4 H1) — `coachLaunch` no longer renders a control (CCH-04
// stands) but now gates whether the page registers its metric as the
// Coach launch context's ambient scope, so the FAB opens contextual.
coachLaunch = false,
showAllValuesType,
children,
}: SubPageShellProps) {
const { t } = useTranslations();
const launch = useCoachLaunch();
// v1.10.2 — the "show all readings" link carries the originating metric
// page as a `from` param so the values sub-page can offer a back-link to
// where the user drilled in from (e.g. `weight → show all values → back to
@@ -171,6 +180,28 @@ export function SubPageShell({
return () => window.cancelAnimationFrame(handle);
}, [focusOnMount]);
// v1.21.0 (C4 H1) — register the page's metric as the Coach launch
// context's ambient scope while the sub-page is mounted, so the global
// FAB opens a conversation pre-scoped to this metric with a data-aware
// seed question. Only when the page opted in (`coachLaunch`) and its
// `explainerMetric` maps to a snapshot source; mobility / gait micro-
// metrics with no map entry leave the FAB on its default snapshot. The
// cleanup lifts the scope on navigation away.
const registerScope = launch?.registerScope;
useEffect(() => {
if (!coachLaunch || !registerScope) return;
const resolved = metricScopeFromExplainer(explainerMetric);
if (!resolved) return;
return registerScope(
{
metric: resolved.metric,
also: resolved.also,
window: resolved.window,
},
resolved.question,
);
}, [coachLaunch, registerScope, explainerMetric]);
return (
// The target-adjust provider bridges the header gear (rendered just
// below) to the per-metric `<TargetEditSheet>` opened from the
@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { LearnMoreLink } from "@/components/ui/learn-more-link";
import { Skeleton } from "@/components/ui/skeleton";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { classifyReferenceRange } from "@/lib/labs/reference-range";
@@ -227,6 +228,8 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
<p className="text-muted-foreground text-sm">{marker.context}</p>
) : null}
<LearnMoreLink concept="LAB_BIOMARKER" />
<Card>
<CardContent className="pt-6">
{isLoading ? (
+3 -3
View File
@@ -7,7 +7,7 @@ import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { DateTimeInput } from "@/components/ui/date-input";
import { DateTimeField } from "@/components/ui/date-time-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
@@ -310,10 +310,10 @@ export function LabForm({
</div>
<div className="space-y-1.5">
<Label htmlFor="lab-takenAt">{t("labs.form.takenAt")}</Label>
<DateTimeInput
<DateTimeField
id="lab-takenAt"
value={takenAt}
onChange={(e) => setTakenAt(e.target.value)}
onChange={setTakenAt}
max={defaultTakenAtValue()}
required
/>
+3 -3
View File
@@ -7,7 +7,7 @@ import { toast } from "sonner";
import { DeleteButton } from "@/components/data-list";
import { Button } from "@/components/ui/button";
import { DateTimeInput } from "@/components/ui/date-input";
import { DateTimeField } from "@/components/ui/date-time-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
@@ -285,10 +285,10 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
</div>
<div className="space-y-1.5">
<Label htmlFor="lab-edit-takenAt">{t("labs.form.takenAt")}</Label>
<DateTimeInput
<DateTimeField
id="lab-edit-takenAt"
value={editTakenAt}
onChange={(e) => setEditTakenAt(e.target.value)}
onChange={setEditTakenAt}
required
/>
</div>
+3 -5
View File
@@ -25,6 +25,7 @@ import {
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
@@ -178,13 +179,10 @@ export function OcrRowEditor({
<Label htmlFor={`${fieldId}-date`} className="text-xs">
{t("labs.ocr.dateLabel")}
</Label>
<Input
<DateField
id={`${fieldId}-date`}
type="date"
value={row.takenAt ?? ""}
onChange={(e) =>
onChange({ ...row, takenAt: e.target.value || null })
}
onChange={(value) => onChange({ ...row, takenAt: value || null })}
/>
</div>
</div>
@@ -64,6 +64,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DateField } from "@/components/ui/date-field";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -598,12 +599,11 @@ export function VorsorgeSection({
<Label htmlFor="vorsorge-anchor">
{t("measurementReminders.form.firstDue")}
</Label>
<Input
<DateField
id="vorsorge-anchor"
type="date"
value={form.anchorDate}
onChange={(e) =>
setForm((f) => ({ ...f, anchorDate: e.target.value }))
onChange={(value) =>
setForm((f) => ({ ...f, anchorDate: value }))
}
/>
</div>
@@ -11,7 +11,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { DateTimeInput } from "@/components/ui/date-input";
import { DateTimeField } from "@/components/ui/date-time-field";
import { Label } from "@/components/ui/label";
import {
Select,
@@ -468,10 +468,10 @@ export function MeasurementForm({
<div className="space-y-2">
<Label htmlFor="measuredAt">{t("measurements.timestamp")}</Label>
<DateTimeInput
<DateTimeField
id="measuredAt"
value={measuredAt}
onChange={(e) => setMeasuredAt(e.target.value)}
onChange={setMeasuredAt}
// v1.17 W1b — mirror the server-side plausibility bound: the
// picker cannot select a future instant. Matches the
// `validateEntryInstant` refine on `measuredAt`.
@@ -66,7 +66,7 @@ import {
MEASUREMENT_NOTES_MAX_LENGTH,
measurementSourceEnum,
} from "@/lib/validations/measurement";
import { DateTimeInput } from "@/components/ui/date-input";
import { DateTimeField } from "@/components/ui/date-time-field";
import { Checkbox } from "@/components/ui/checkbox";
import {
SortableHead,
@@ -1339,10 +1339,10 @@ export function MeasurementList({
<Label htmlFor="edit-measuredAt">
{t("measurements.timestamp")}
</Label>
<DateTimeInput
<DateTimeField
id="edit-measuredAt"
value={editMeasuredAt}
onChange={(e) => setEditMeasuredAt(e.target.value)}
onChange={setEditMeasuredAt}
// v1.17 W1b — match the server bound: no future instant.
max={toDateTimeLocalValue(new Date().toISOString())}
required
@@ -26,6 +26,33 @@ vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({}),
}));
// DateTimeField reads the client date/hour-cycle preference context, which the
// wholesale i18n mock above strips. The dialog test pins the field's value/max
// contract, not the field's own rendering, so a passthrough native input keeps
// the assertions meaningful while staying SSR-renderable.
vi.mock("@/components/ui/date-time-field", () => ({
DateTimeField: ({
id,
value,
max,
disabled,
}: {
id?: string;
value?: string;
max?: string;
disabled?: boolean;
}) => (
<input
id={id}
type="datetime-local"
value={value}
max={max}
disabled={disabled}
readOnly
/>
),
}));
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ children }: { children: React.ReactNode }) => (
<div data-slot="mock-dialog">{children}</div>
@@ -32,7 +32,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { DateTimeField } from "@/components/ui/date-time-field";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
@@ -227,12 +227,11 @@ export function LedgerAddDialog({
<Label htmlFor="ledger-add-taken-at">
{t("medications.detail.verlauf.addDialog.takenAtLabel")}
</Label>
<Input
<DateTimeField
id="ledger-add-taken-at"
type="datetime-local"
value={takenAt}
max={toDateTimeLocal(new Date())}
onChange={(e) => setTakenAt(e.target.value)}
onChange={(value) => setTakenAt(value)}
disabled={skipped}
/>
</div>
@@ -34,7 +34,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { DateTimeField } from "@/components/ui/date-time-field";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
@@ -166,12 +166,11 @@ function IntakeEditDialogBody({
<Label htmlFor="intake-edit-taken-at">
{t("medications.detail.intake.edit.takenAtLabel")}
</Label>
<Input
<DateTimeField
id="intake-edit-taken-at"
type="datetime-local"
value={takenAt}
max={toDateTimeLocal(new Date())}
onChange={(e) => setTakenAt(e.target.value)}
onChange={(value) => setTakenAt(value)}
disabled={skipped}
/>
{scheduledFor && (
@@ -35,6 +35,7 @@ import {
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { DateTimeField } from "@/components/ui/date-time-field";
import { NativeSelect } from "@/components/ui/native-select";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
@@ -297,12 +298,11 @@ export function LogIntakeDialog({
<Label htmlFor="log-intake-taken-at">
{t("medications.logIntake.takenAtLabel")}
</Label>
<Input
<DateTimeField
id="log-intake-taken-at"
type="datetime-local"
value={takenAt}
max={toDateTimeLocal(new Date())}
onChange={(e) => setTakenAt(e.target.value)}
onChange={(value) => setTakenAt(value)}
disabled={skipped}
/>
<p className="text-muted-foreground text-xs">
@@ -49,6 +49,7 @@
import { useCallback, useId, useMemo } from "react";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
@@ -602,13 +603,11 @@ function YearlyDate({
<Label htmlFor={id} className="text-sm">
{t(`${i18nPrefix}.yearly.date.label`)}
</Label>
<Input
<DateField
id={id}
type="date"
value={value}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
className="h-11"
onChange={onChange}
data-slot="cadence-yearly-date"
/>
</div>
@@ -25,7 +25,7 @@
import { useCallback, useId, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { DateField } from "@/components/ui/date-field";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
@@ -135,15 +135,15 @@ export function CourseWindowRow({
<Label htmlFor={startId} className="text-sm">
{t(`${i18nPrefix}.startsOn.label`)}
</Label>
<Input
<DateField
id={startId}
type="date"
value={startsIso}
disabled={disabled}
onChange={(e) => onStartsChange(e.target.value)}
className="h-11 w-full"
onChange={onStartsChange}
className="w-full"
aria-label={t(`${i18nPrefix}.startsOn.label`)}
data-slot="course-window-starts"
data-testid="course-window-starts-field"
/>
</div>
@@ -152,17 +152,16 @@ export function CourseWindowRow({
<Label htmlFor={endId} className="text-sm">
{t(`${i18nPrefix}.endsOn.label`)}
</Label>
<Input
<DateField
id={endId}
type="date"
value={endsIso}
disabled={disabled || noEndDate || lockEndsToStart}
readOnly={lockEndsToStart}
onChange={(e) => onEndsChange(e.target.value)}
className="h-11 w-full"
onChange={onEndsChange}
className="w-full"
aria-label={t(`${i18nPrefix}.endsOn.label`)}
aria-invalid={!valid || undefined}
data-slot="course-window-ends"
data-testid="course-window-ends-field"
/>
{lockEndsToStart && (
<p
@@ -59,7 +59,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { DateInput } from "@/components/ui/date-input";
import { DateField } from "@/components/ui/date-field";
import { Label } from "@/components/ui/label";
import { TimesOfDayChips } from "@/components/medications/scheduling/times-of-day-chips";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
@@ -545,22 +545,22 @@ function EraDialog({
<Label htmlFor="zeitplan-history-from">
{t("medications.detail.zeitplan.history.fromLabel")}
</Label>
<DateInput
<DateField
id="zeitplan-history-from"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
data-slot="zeitplan-history-from"
onChange={setFromDate}
data-testid="zeitplan-history-from"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="zeitplan-history-until">
{t("medications.detail.zeitplan.history.untilLabel")}
</Label>
<DateInput
<DateField
id="zeitplan-history-until"
value={untilDate}
onChange={(e) => setUntilDate(e.target.value)}
data-slot="zeitplan-history-until"
onChange={setUntilDate}
data-testid="zeitplan-history-until"
/>
</div>
</div>
@@ -56,6 +56,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { DateField } from "@/components/ui/date-field";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import {
@@ -649,12 +650,10 @@ export function AddInventoryDialog({
>
{t("medications.detail.bestand.addExpiryLabel")}
</label>
<Input
<DateField
id="inventory-add-expiry"
type="date"
autoComplete="off"
value={expiry}
onChange={(e) => setExpiry(e.target.value)}
onChange={setExpiry}
/>
</div>
<DialogFooter>
+3 -3
View File
@@ -22,7 +22,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { DateTimeInput } from "@/components/ui/date-input";
import { DateTimeField } from "@/components/ui/date-time-field";
import { Label } from "@/components/ui/label";
import {
ListChecks,
@@ -511,10 +511,10 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
<div className="space-y-2">
<Label htmlFor="mood-logged-at">{t("mood.timestamp")}</Label>
<DateTimeInput
<DateTimeField
id="mood-logged-at"
value={moodLoggedAt}
onChange={(e) => setMoodLoggedAt(e.target.value)}
onChange={setMoodLoggedAt}
// v1.17 W1b — match the server bound: no future instant.
max={getDefaultMoodLoggedAtValue()}
required
+3 -3
View File
@@ -29,7 +29,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { DateTimeInput } from "@/components/ui/date-input";
import { DateTimeField } from "@/components/ui/date-time-field";
import { Label } from "@/components/ui/label";
import {
Table,
@@ -844,10 +844,10 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
<div className="space-y-2">
<Label htmlFor="edit-mood-logged-at">{t("mood.timestamp")}</Label>
<DateTimeInput
<DateTimeField
id="edit-mood-logged-at"
value={editMoodLoggedAt}
onChange={(e) => setEditMoodLoggedAt(e.target.value)}
onChange={setEditMoodLoggedAt}
// v1.17 W1b — match the server bound: no future instant.
max={toDateTimeLocalValue(new Date().toISOString())}
required
+3 -3
View File
@@ -8,7 +8,7 @@ import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { DateInput } from "@/components/ui/date-input";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@@ -242,10 +242,10 @@ export function BaselineForm() {
<Label htmlFor="ob-baseline-dob">
{t("onboarding.baseline.dateOfBirthLabel")}
</Label>
<DateInput
<DateField
id="ob-baseline-dob"
value={form.dateOfBirth}
onChange={(e) => patch("dateOfBirth", e.target.value)}
onChange={(value) => patch("dateOfBirth", value)}
max={new Date().toISOString().slice(0, 10)}
autoComplete="bday"
/>
@@ -68,6 +68,7 @@ function buildUser(disableCoach: boolean): AuthUser {
glucoseUnit: null,
unitPreference: "metric",
timeFormat: "AUTO",
dateFormat: "AUTO",
disableCoach,
fullName: null,
insurerName: null,
@@ -70,6 +70,7 @@ function buildUser(modules: AuthUser["modules"]): AuthUser {
glucoseUnit: null,
unitPreference: "metric",
timeFormat: "AUTO",
dateFormat: "AUTO",
disableCoach: false,
fullName: null,
insurerName: null,
@@ -46,6 +46,7 @@ function buildUser(timeFormat: TimeFormatPreference): AuthUser {
glucoseUnit: null,
unitPreference: "metric",
timeFormat,
dateFormat: "AUTO",
disableCoach: false,
fullName: null,
insurerName: null,
@@ -49,6 +49,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser {
glucoseUnit: null,
unitPreference,
timeFormat: "AUTO",
dateFormat: "AUTO",
disableCoach: false,
fullName: null,
insurerName: null,
@@ -13,7 +13,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { DateInput } from "@/components/ui/date-input";
import { DateField } from "@/components/ui/date-field";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { PasswordInput } from "@/components/ui/password-input";
@@ -27,6 +27,7 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TimezonePicker } from "@/components/settings/timezone-picker";
import { TimeFormatSelect } from "@/components/settings/time-format-select";
import { DateFormatSelect } from "@/components/settings/date-format-select";
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card";
import { detectBrowserTimezone } from "@/lib/tz/format";
@@ -372,10 +373,10 @@ export function AccountSection() {
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="dob">{t("settings.dateOfBirth")}</Label>
<DateInput
<DateField
id="dob"
value={dateOfBirth}
onChange={(e) => setDateOfBirth(e.target.value)}
onChange={setDateOfBirth}
max={new Date().toISOString().slice(0, 10)}
/>
<p className="text-muted-foreground text-xs">
@@ -416,6 +417,7 @@ export function AccountSection() {
<TimezonePicker value={timezone} onChange={setTimezone} />
<UnitPreferenceSelect isAuthenticated={isAuthenticated} />
<TimeFormatSelect isAuthenticated={isAuthenticated} />
<DateFormatSelect isAuthenticated={isAuthenticated} />
</div>
{/* v1.7.0 — optional patient-identity fields surfaced on the
@@ -0,0 +1,144 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { useAuth } from "@/hooks/use-auth";
import type { DateFormatPreference } from "@/lib/format-locale";
import { DATE_FORMAT_OPTIONS, storeDateFormat } from "@/lib/date-format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw } from "@/lib/api/api-fetch";
/**
* Date-order (DD.MM / MM/DD / ISO) preference as a Profile dropdown.
*
* Sits directly below the hour-format select because it is the same kind of
* personal display preference; the active locale otherwise pins the date
* order with no way out. AUTO follows the locale convention, DMY/MDY/YMD pin
* the field order regardless of locale.
*
* Persistence mirrors `<TimeFormatSelect>`: a change PATCHes the profile
* endpoint immediately and invalidates `queryKeys.authMe()`. The localStorage
* mirror is written synchronously so every `useFormatters()` consumer and the
* `<DateField>` primitive repaint without waiting for the refetch.
*/
export function DateFormatSelect({
isAuthenticated,
id = "date-format",
}: {
isAuthenticated: boolean;
id?: string;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { user } = useAuth();
const [optimistic, setOptimistic] = useState<DateFormatPreference | null>(
null,
);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
};
}, []);
function scheduleClear() {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
}
clearTimerRef.current = setTimeout(() => {
clearTimerRef.current = null;
setMsg(null);
setMsgType(null);
}, 3000);
}
const value: DateFormatPreference = optimistic ?? user?.dateFormat ?? "AUTO";
const mutation = useMutation({
mutationFn: async (next: DateFormatPreference) => {
const res = await apiFetchRaw("/api/user/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ dateFormat: next }),
});
if (!res.ok) {
const json = (await res.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(json.error ?? `HTTP ${res.status}`);
}
return next;
},
onSuccess: (next) => {
// Repaint every formatter consumer right away; the /me refetch below
// re-asserts the same value once it settles.
storeDateFormat(next);
setMsg(t("settings.dateFormat.saved"));
setMsgType("success");
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
setOptimistic(null);
scheduleClear();
},
onError: (err) => {
setOptimistic(null);
setMsg(
err instanceof Error ? err.message : t("settings.dateFormat.saveError"),
);
setMsgType("error");
scheduleClear();
},
});
function handleSelect(next: DateFormatPreference) {
if (next === value || mutation.isPending || !isAuthenticated) return;
setOptimistic(next);
setMsg(null);
setMsgType(null);
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
mutation.mutate(next);
}
return (
<div className="space-y-2">
<Label htmlFor={id}>{t("settings.dateFormat.title")}</Label>
<NativeSelect
id={id}
data-testid="settings-date-format-select"
value={value}
disabled={!isAuthenticated || mutation.isPending}
onChange={(e) => handleSelect(e.target.value as DateFormatPreference)}
>
{DATE_FORMAT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{t(opt.labelKey)}
</option>
))}
</NativeSelect>
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive text-xs"
: "text-muted-foreground text-xs"
}
>
{msg ?? t("settings.dateFormat.description")}
</p>
</div>
);
}
+5 -5
View File
@@ -36,7 +36,7 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { DateInput } from "@/components/ui/date-input";
import { DateField } from "@/components/ui/date-field";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -181,10 +181,10 @@ function DateRangeFields({
<Label htmlFor={`${idPrefix}-since`} className="text-xs">
{t("settings.sections.export.filters.since")}
</Label>
<DateInput
<DateField
id={`${idPrefix}-since`}
value={since}
onChange={(e) => setSince(e.target.value)}
onChange={setSince}
max={until || undefined}
/>
</div>
@@ -192,10 +192,10 @@ function DateRangeFields({
<Label htmlFor={`${idPrefix}-until`} className="text-xs">
{t("settings.sections.export.filters.until")}
</Label>
<DateInput
<DateField
id={`${idPrefix}-until`}
value={until}
onChange={(e) => setUntil(e.target.value)}
onChange={setUntil}
min={since || undefined}
/>
</div>
@@ -0,0 +1,87 @@
/**
* `<DateField>` contract (v1.21.0).
*
* The SSR-only convention (no `@testing-library/react`, `environment: "node"`)
* means the interactive picker / typed-entry paths can't be driven here — those
* ride `showPicker()` + change events the static renderer never fires. What the
* static markup CAN pin is the load-bearing contract:
*
* - the committed VALUE stays ISO `yyyy-MM-dd` on a hidden native date input,
* so this is a drop-in for `<DateInput>` and react-hook-form is unchanged;
* - the visible overlay paints the value formatted per the preference;
* - disabled / min / max / placeholder thread through;
* - height + target-size parity classes are present.
*
* Under SSR `useDateFormatPreference()` resolves AUTO (no `window`), so the
* overlay reflects the locale order — de → dd.MM.yyyy, en → MM/dd/yyyy. The
* per-preference order itself is covered exhaustively in
* `src/lib/__tests__/date-format.test.ts` against the pure formatter.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { DateField } from "../date-field";
function render(node: React.ReactNode, locale: "de" | "en" = "en"): string {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<DateField>", () => {
it("keeps the ISO value on a hidden native date input", () => {
const html = render(<DateField id="dob" name="dob" value="2026-02-19" />);
// The real value lives on a type=date input (the form's source of truth).
expect(html).toMatch(/<input[^>]*type="date"[^>]*value="2026-02-19"/);
expect(html).toContain('name="dob"');
// …and it is visually hidden so only the overlay shows.
expect(html).toMatch(/<input[^>]*type="date"[^>]*class="[^"]*sr-only/);
});
it("paints the AUTO-locale display string over the value (en → MM/dd/yyyy)", () => {
const html = render(<DateField value="2026-02-19" />, "en");
expect(html).toContain('value="02/19/2026"');
});
it("paints the AUTO-locale display string over the value (de → dd.MM.yyyy)", () => {
const html = render(<DateField value="2026-02-19" />, "de");
expect(html).toContain('value="19.02.2026"');
});
it("renders the placeholder shape when empty", () => {
const html = render(<DateField value="" placeholder="Pick a date" />);
expect(html).toContain('placeholder="Pick a date"');
});
it("derives a format-shaped placeholder when none is supplied (en → MM/DD/YYYY)", () => {
const html = render(<DateField value="" />, "en");
expect(html).toContain('placeholder="MM/DD/YYYY"');
});
it("threads disabled, min and max onto the native input", () => {
const html = render(
<DateField
value="2026-02-19"
disabled
min="2020-01-01"
max="2030-12-31"
/>,
);
expect(html).toMatch(/<input[^>]*type="date"[^>]*min="2020-01-01"/);
expect(html).toMatch(/<input[^>]*type="date"[^>]*max="2030-12-31"/);
expect(html).toMatch(/<input[^>]*type="date"[^>]*disabled/);
});
it("ships the WCAG target-size + height-parity classes", () => {
const html = render(<DateField value="2026-02-19" />);
// Same 44px mobile / 40px sm+ floor as <DateInput>.
expect(html).toContain("min-h-11");
expect(html).toContain("sm:h-10");
});
it("exposes a labelled calendar picker affordance", () => {
const html = render(<DateField value="2026-02-19" />);
expect(html).toContain('aria-label="Open date picker"');
});
});
@@ -0,0 +1,93 @@
/**
* `<DateTimeField>` contract (v1.21.0).
*
* Like `<DateField>`, the SSR-only convention (`renderToStaticMarkup`,
* `environment: "node"`) means the interactive picker path can't be driven
* here. What the static markup pins is the load-bearing contract:
*
* - the committed VALUE stays a local `yyyy-MM-ddTHH:mm` string on a hidden
* native datetime-local input, so this is a drop-in for `<DateTimeInput>`;
* - the visible overlay paints the date in the date-order preference and the
* time in the hour-cycle preference;
* - disabled / min / max / placeholder thread through;
* - height + target-size parity classes are present.
*
* Under SSR both preferences resolve AUTO (no `window`), so the overlay follows
* the locale: de → "31.12.2026 14:05" (24h), en → "12/31/2026 02:05 PM".
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { DateTimeField } from "../date-time-field";
function render(node: React.ReactNode, locale: "de" | "en" = "en"): string {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<DateTimeField>", () => {
it("keeps the local datetime value on a hidden native datetime-local input", () => {
const html = render(
<DateTimeField id="ts" name="ts" value="2026-12-31T14:05" />,
);
expect(html).toMatch(
/<input[^>]*type="datetime-local"[^>]*value="2026-12-31T14:05"/,
);
expect(html).toContain('name="ts"');
expect(html).toMatch(
/<input[^>]*type="datetime-local"[^>]*class="[^"]*sr-only/,
);
});
it("paints the AUTO-locale display (de → dd.MM.yyyy + 24h)", () => {
const html = render(<DateTimeField value="2026-12-31T14:05" />, "de");
expect(html).toContain('value="31.12.2026 14:05"');
});
it("paints the AUTO-locale display (en → MM/dd/yyyy + 12h)", () => {
const html = render(<DateTimeField value="2026-12-31T14:05" />, "en");
// en-US default hour cycle is AM/PM.
expect(html).toMatch(/value="12\/31\/2026 02:05\s?PM"/);
});
it("renders the supplied placeholder when empty", () => {
const html = render(<DateTimeField value="" placeholder="Pick a moment" />);
expect(html).toContain('placeholder="Pick a moment"');
});
it("derives a format-shaped placeholder when none is supplied (en)", () => {
const html = render(<DateTimeField value="" />, "en");
expect(html).toContain('placeholder="MM/DD/YYYY --:--"');
});
it("threads disabled, min and max onto the native input", () => {
const html = render(
<DateTimeField
value="2026-12-31T14:05"
disabled
min="2020-01-01T00:00"
max="2030-12-31T23:59"
/>,
);
expect(html).toMatch(
/<input[^>]*type="datetime-local"[^>]*min="2020-01-01T00:00"/,
);
expect(html).toMatch(
/<input[^>]*type="datetime-local"[^>]*max="2030-12-31T23:59"/,
);
expect(html).toMatch(/<input[^>]*type="datetime-local"[^>]*disabled/);
});
it("ships the WCAG target-size + height-parity classes", () => {
const html = render(<DateTimeField value="2026-12-31T14:05" />);
expect(html).toContain("min-h-11");
expect(html).toContain("sm:h-10");
});
it("exposes a labelled calendar picker affordance", () => {
const html = render(<DateTimeField value="2026-12-31T14:05" />);
expect(html).toContain('aria-label="Open date picker"');
});
});
+292
View File
@@ -0,0 +1,292 @@
"use client";
import * as React from "react";
import { Calendar } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations, useDateFormatPreference } from "@/lib/i18n/context";
import { formatDate, parseIsoDate } from "@/lib/date-format";
/**
* Dependency-free date input that DISPLAYS the value in the user's date-order
* preference (AUTO follows the locale; DMY / MDY / YMD pin the field order)
* while editing rides the browser's native date picker.
*
* The committed VALUE is always ISO `yyyy-MM-dd` — identical to the native
* `<input type="date">` contract `<DateInput>` ships — so this is a drop-in
* for `<DateInput>` and existing react-hook-form wiring is unchanged.
*
* How it works:
* - A visually-hidden native `<input type="date">` holds the real value and
* emits the change events the form listens to. Tapping the field (or its
* calendar button) calls `showPicker()` on that input, falling back to
* `focus()` where the API is unavailable.
* - A text overlay paints the formatted display string (or the placeholder).
* - As a progressive enhancement the user can type a date directly: the
* overlay is an editable text input; on blur / Enter we parse what they
* typed against the active locale order and, on a clean parse, write the
* ISO value back through the hidden input so the form sees a normal change.
*
* Height / a11y / target-size parity with `<DateInput>`: the same
* `min-h-11 h-11 sm:min-h-10 sm:h-10` floor (WCAG 2.5.5 — 44 px on mobile,
* 40 px on sm+) and focus vocabulary as the rest of the input primitives.
*/
// Mirrors the `<DateInput>` height contract (minus the date-shadow rule, which
// only applies to the native value text — here the native input is hidden).
const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10";
const FIELD_BASE_CLASSES =
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-within:ring-ring relative flex w-full items-center rounded-md border ps-3 pe-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-2 focus-within:ring-offset-2 focus-within:outline-none";
export interface DateFieldProps {
id?: string;
name?: string;
/** ISO `yyyy-MM-dd` (controlled). */
value?: string;
/** Initial ISO `yyyy-MM-dd` (uncontrolled). */
defaultValue?: string;
onChange?: (value: string) => void;
onBlur?: () => void;
disabled?: boolean;
required?: boolean;
/** Autofill hint forwarded to the native date input (e.g. "bday"). */
autoComplete?: string;
/** ISO `yyyy-MM-dd`. */
min?: string;
/** ISO `yyyy-MM-dd`. */
max?: string;
placeholder?: string;
className?: string;
"aria-label"?: string;
"aria-describedby"?: string;
"aria-invalid"?: boolean | "true" | "false";
"data-testid"?: string;
}
export const DateField = React.forwardRef<HTMLInputElement, DateFieldProps>(
function DateField(
{
id,
name,
value,
defaultValue,
onChange,
onBlur,
disabled,
required,
autoComplete,
min,
max,
placeholder,
className,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
"aria-invalid": ariaInvalid,
"data-testid": dataTestId,
...rest
},
forwardedRef,
) {
const { locale, t } = useTranslations();
const dateFormat = useDateFormatPreference();
const innerRef = React.useRef<HTMLInputElement | null>(null);
const setRefs = React.useCallback(
(node: HTMLInputElement | null) => {
innerRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
},
[forwardedRef],
);
// Track the ISO value so the overlay reflects controlled + uncontrolled
// updates. Controlled `value` always wins on render.
const [internal, setInternal] = React.useState(defaultValue ?? "");
const iso = value ?? internal;
// Mirror what the user is typing into the overlay; cleared back to the
// formatted string whenever the committed ISO value changes.
const [typed, setTyped] = React.useState<string | null>(null);
const display = formatDate(iso, dateFormat, locale);
const overlayValue = typed ?? display;
function commitIso(next: string) {
if (value === undefined) setInternal(next);
setTyped(null);
onChange?.(next);
}
function openPicker() {
const el = innerRef.current;
if (!el || disabled) return;
// showPicker() throws if not user-activated or unsupported — fall back
// to focusing the (hidden) input, which still opens the picker on most
// mobile engines.
try {
(el as HTMLInputElement & { showPicker?: () => void }).showPicker?.();
} catch {
el.focus();
}
}
function handleNativeChange(e: React.ChangeEvent<HTMLInputElement>) {
commitIso(e.target.value);
}
// Progressive enhancement: parse free-typed text against the active locale
// order. We feed the typed string to the browser's own parser via a throw-
// away Date only when it matches the ISO shape; otherwise we accept a small
// set of separator-flexible numeric forms keyed off the preference.
function commitTyped() {
if (typed === null) return;
const trimmed = typed.trim();
if (trimmed === "") {
commitIso("");
return;
}
const parsed = parseTypedDate(trimmed, dateFormat, locale);
if (parsed) {
commitIso(parsed);
} else {
// Unparseable — discard the draft and snap back to the committed value.
setTyped(null);
}
}
return (
<div
className={cn(FIELD_BASE_CLASSES, FIELD_HEIGHT_CLASSES, className)}
data-slot="date-field"
data-disabled={disabled ? "" : undefined}
>
{/* Visually-hidden native date input — owns the real value + events. */}
<input
{...rest}
ref={setRefs}
id={id}
name={name}
type="date"
lang={locale}
className="sr-only"
tabIndex={-1}
aria-hidden="true"
value={value !== undefined ? value : undefined}
defaultValue={value === undefined ? defaultValue : undefined}
disabled={disabled}
required={required}
autoComplete={autoComplete}
min={min}
max={max}
onChange={handleNativeChange}
/>
{/* Editable display overlay. */}
<input
type="text"
inputMode="numeric"
autoComplete="off"
data-testid={dataTestId}
className="placeholder:text-muted-foreground h-full flex-1 bg-transparent py-1 outline-none disabled:cursor-not-allowed"
value={overlayValue}
placeholder={placeholder ?? formatPlaceholder(dateFormat, locale)}
disabled={disabled}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
aria-invalid={ariaInvalid}
onChange={(e) => setTyped(e.target.value)}
onClick={openPicker}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitTyped();
}
}}
onBlur={() => {
commitTyped();
onBlur?.();
}}
/>
<button
type="button"
tabIndex={-1}
disabled={disabled}
aria-label={t("common.openDatePicker")}
className="text-muted-foreground hover:text-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-md disabled:cursor-not-allowed disabled:opacity-50"
onClick={openPicker}
>
<Calendar className="h-4 w-4" aria-hidden="true" />
</button>
</div>
);
},
);
/** Placeholder that mirrors the active field order, e.g. "DD.MM.YYYY". */
function formatPlaceholder(
dateFormat: ReturnType<typeof useDateFormatPreference>,
locale: ReturnType<typeof useTranslations>["locale"],
): string {
// 2026-12-31 renders unambiguously in every order, so its formatted shape
// doubles as a self-describing placeholder hint.
return formatDate("2026-12-31", dateFormat, locale)
.replace(/31/g, "DD")
.replace(/12/g, "MM")
.replace(/2026/g, "YYYY");
}
/**
* Best-effort parse of a free-typed date into ISO `yyyy-MM-dd`. Accepts the
* active preference's field order with `.`, `/`, or `-` separators, plus a
* bare ISO string. Returns null when the parts don't form a real calendar
* date so the caller can reject the draft.
*/
function parseTypedDate(
input: string,
dateFormat: ReturnType<typeof useDateFormatPreference>,
locale: ReturnType<typeof useTranslations>["locale"],
): string | null {
// A clean ISO string always wins.
const isoDirect = parseIsoDate(input);
if (isoDirect) return toIso(isoDirect);
const parts = input.split(/[./-]/).map((p) => p.trim());
if (parts.length !== 3 || parts.some((p) => p === "" || !/^\d+$/.test(p))) {
return null;
}
const nums = parts.map(Number) as [number, number, number];
const order = resolveOrder(dateFormat, locale);
let year: number, month: number, day: number;
if (order === "MDY") [month, day, year] = nums;
else if (order === "YMD") [year, month, day] = nums;
else [day, month, year] = nums; // DMY
if (year < 100) year += year >= 70 ? 1900 : 2000;
const candidate = `${pad(year, 4)}-${pad(month, 2)}-${pad(day, 2)}`;
const round = parseIsoDate(candidate);
return round ? candidate : null;
}
function resolveOrder(
dateFormat: ReturnType<typeof useDateFormatPreference>,
locale: ReturnType<typeof useTranslations>["locale"],
): "DMY" | "MDY" | "YMD" {
if (dateFormat === "DMY") return "DMY";
if (dateFormat === "MDY") return "MDY";
if (dateFormat === "YMD") return "YMD";
// AUTO: en → MDY, every other shipped locale → DMY.
return locale === "en" ? "MDY" : "DMY";
}
function toIso(date: Date): string {
return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1, 2)}-${pad(
date.getUTCDate(),
2,
)}`;
}
function pad(value: number, width: number): string {
return String(value).padStart(width, "0");
}
-63
View File
@@ -1,63 +0,0 @@
"use client";
import * as React from "react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
* Native date input that hints the active locale via `lang=`. Browsers
* use this hint to render the calendar widget in dd.MM.yyyy / MM/dd/yyyy
* order, which is the user-visible format we want to follow the app's
* EN/DE preference. Wrapping the prop here keeps every callsite from
* having to pull `locale` out of `useTranslations()` just to feed it
* back into the same JSX prop, and guarantees a future date input
* cannot silently regress by forgetting the hint.
*
* v1.4.33 — height parity with `<Input>` and `<NativeSelect>`.
*
* Mobile Safari + Android Chrome give `type="date"` (and
* `type="datetime-local"`) an intrinsic chrome that puffs the
* field past Tailwind's `h-10`, so the DOB cell on
* `/settings/account` rendered ~6 px taller than its row siblings
* and roughly 12 px wider on the Pixel-5 mobile-pass screenshots.
* `appearance-none` switches the native widget off, then
* `min-h-10` re-asserts the contract that every other input
* primitive ships. The `:webkit-date-and-time-value` shadow rule
* forces the embedded value text to occupy at least one full line
* even when iOS shrinks its placeholder, so the field looks
* identical on both engines.
*/
// v1.4.34 IW-G — match the `<Input>` mobile floor (44 px) so date and
// datetime fields don't sit below the WCAG 2.5.5 target-size minimum
// on Pixel-5-class viewports. Desktop falls back to the 40 px norm.
const DATE_INPUT_HEIGHT_CLASSES =
"appearance-none min-h-11 h-11 sm:min-h-10 sm:h-10 [&::-webkit-date-and-time-value]:min-h-[1.5em]";
type InputProps = React.ComponentProps<typeof Input>;
type DateInputProps = Omit<InputProps, "type" | "lang">;
export function DateInput({ className, ...props }: DateInputProps) {
const { locale } = useTranslations();
return (
<Input
type="date"
lang={locale}
className={cn(DATE_INPUT_HEIGHT_CLASSES, className)}
{...props}
/>
);
}
export function DateTimeInput({ className, ...props }: DateInputProps) {
const { locale } = useTranslations();
return (
<Input
type="datetime-local"
lang={locale}
className={cn(DATE_INPUT_HEIGHT_CLASSES, className)}
{...props}
/>
);
}
+280
View File
@@ -0,0 +1,280 @@
"use client";
import * as React from "react";
import { Calendar } from "lucide-react";
import { cn } from "@/lib/utils";
import {
useTranslations,
useDateFormatPreference,
useTimeFormatPreference,
} from "@/lib/i18n/context";
import { formatDate, resolveDateLocale } from "@/lib/date-format";
/**
* Dependency-free datetime input that DISPLAYS the value in the user's
* date-order preference (date part) and hour-cycle preference (time part)
* while editing rides the browser's native datetime picker.
*
* The committed VALUE is always a local `yyyy-MM-ddTHH:mm` string — identical
* to the native `<input type="datetime-local">` contract `<DateTimeInput>`
* ships — so this is a drop-in for `<DateTimeInput>`. The value is a wall-clock
* local instant (no zone), so display parsing reads the literal parts rather
* than re-interpreting them through a timezone.
*
* How it works mirrors `<DateField>`:
* - A visually-hidden native `<input type="datetime-local">` holds the real
* value and emits the change events the form listens to. Tapping the field
* (or its calendar button) calls `showPicker()`, falling back to `focus()`.
* - A read-only text overlay paints the formatted display string (date in
* the date-order preference, time in the hour-cycle preference) or the
* placeholder. Free-typed entry is not offered — the datetime parts are
* ambiguous enough that the native picker is the only edit path, same as
* the original `<DateTimeInput>` (which exposed no text fallback either).
*
* Height / a11y / target-size parity with `<DateTimeInput>`: the same
* `min-h-11 h-11 sm:min-h-10 sm:h-10` floor (WCAG 2.5.5) and focus vocabulary.
*/
// Mirrors the `<DateInput>` / `<DateTimeInput>` height contract.
const FIELD_HEIGHT_CLASSES = "min-h-11 h-11 sm:min-h-10 sm:h-10";
const FIELD_BASE_CLASSES =
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-within:ring-ring relative flex w-full items-center rounded-md border ps-3 pe-2 text-sm shadow-xs transition-[color,box-shadow] focus-within:ring-2 focus-within:ring-offset-2 focus-within:outline-none";
export interface DateTimeFieldProps {
id?: string;
name?: string;
/** Local `yyyy-MM-ddTHH:mm` (controlled). */
value?: string;
/** Initial local `yyyy-MM-ddTHH:mm` (uncontrolled). */
defaultValue?: string;
onChange?: (value: string) => void;
onBlur?: () => void;
disabled?: boolean;
required?: boolean;
/** Local `yyyy-MM-ddTHH:mm`. */
min?: string;
/** Local `yyyy-MM-ddTHH:mm`. */
max?: string;
placeholder?: string;
className?: string;
"aria-label"?: string;
"aria-describedby"?: string;
"aria-invalid"?: boolean | "true" | "false";
"aria-required"?: boolean | "true" | "false";
"data-testid"?: string;
}
export const DateTimeField = React.forwardRef<
HTMLInputElement,
DateTimeFieldProps
>(function DateTimeField(
{
id,
name,
value,
defaultValue,
onChange,
onBlur,
disabled,
required,
min,
max,
placeholder,
className,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
"aria-invalid": ariaInvalid,
"aria-required": ariaRequired,
"data-testid": dataTestId,
...rest
},
forwardedRef,
) {
const { locale, t } = useTranslations();
const dateFormat = useDateFormatPreference();
const timeFormat = useTimeFormatPreference();
const innerRef = React.useRef<HTMLInputElement | null>(null);
const setRefs = React.useCallback(
(node: HTMLInputElement | null) => {
innerRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
},
[forwardedRef],
);
const [internal, setInternal] = React.useState(defaultValue ?? "");
const local = value ?? internal;
const display = formatLocalDateTime(local, dateFormat, timeFormat, locale);
function commit(next: string) {
if (value === undefined) setInternal(next);
onChange?.(next);
}
function openPicker() {
const el = innerRef.current;
if (!el || disabled) return;
try {
(el as HTMLInputElement & { showPicker?: () => void }).showPicker?.();
} catch {
el.focus();
}
}
return (
<div
className={cn(FIELD_BASE_CLASSES, FIELD_HEIGHT_CLASSES, className)}
data-slot="date-time-field"
data-disabled={disabled ? "" : undefined}
>
{/* Visually-hidden native datetime input — owns the real value + events. */}
<input
{...rest}
ref={setRefs}
id={id}
name={name}
type="datetime-local"
lang={locale}
className="sr-only"
tabIndex={-1}
aria-hidden="true"
value={value !== undefined ? value : undefined}
defaultValue={value === undefined ? defaultValue : undefined}
disabled={disabled}
required={required}
min={min}
max={max}
onChange={(e) => commit(e.target.value)}
/>
{/* Read-only display overlay. */}
<input
type="text"
readOnly
autoComplete="off"
data-testid={dataTestId}
className="placeholder:text-muted-foreground h-full flex-1 cursor-default bg-transparent py-1 outline-none disabled:cursor-not-allowed"
value={display}
placeholder={placeholder ?? formatPlaceholder(dateFormat, locale)}
disabled={disabled}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
aria-invalid={ariaInvalid}
aria-required={ariaRequired}
onClick={openPicker}
onFocus={openPicker}
onBlur={onBlur}
/>
<button
type="button"
tabIndex={-1}
disabled={disabled}
aria-label={t("common.openDatePicker")}
className="text-muted-foreground hover:text-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-md disabled:cursor-not-allowed disabled:opacity-50"
onClick={openPicker}
>
<Calendar className="h-4 w-4" aria-hidden="true" />
</button>
</div>
);
});
/**
* Format a local `yyyy-MM-ddTHH:mm` string into "<date> <time>" where the
* date part follows the date-order preference and the time part follows the
* hour-cycle preference. The value is a wall-clock instant (no zone), so the
* literal parts are read directly and rendered with `timeZone: "UTC"` to keep
* the displayed clock identical to what the user typed. Returns "" for an
* empty / malformed value so the field paints its placeholder.
*/
function formatLocalDateTime(
value: string,
dateFormat: ReturnType<typeof useDateFormatPreference>,
timeFormat: ReturnType<typeof useTimeFormatPreference>,
locale: ReturnType<typeof useTranslations>["locale"],
): string {
const parts = parseLocalDateTime(value);
if (!parts) return "";
const datePart = formatDate(parts.date, dateFormat, locale);
if (datePart === "") return "";
// Anchor the wall-clock time in UTC so the rendered clock matches the typed
// parts exactly, regardless of the viewer's timezone.
const anchored = new Date(
Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute),
);
const timePart = anchored.toLocaleTimeString(
resolveDateLocale(dateFormat, locale),
{
timeZone: "UTC",
hour: "2-digit",
minute: "2-digit",
...hourCycleOptions(timeFormat),
},
);
return `${datePart} ${timePart}`;
}
/** Mirror of `format-locale.ts` `hourCycleOptions`, kept local to the field. */
function hourCycleOptions(
timeFormat: ReturnType<typeof useTimeFormatPreference>,
): Intl.DateTimeFormatOptions {
switch (timeFormat) {
case "H12":
return { hour12: true };
case "H24":
return { hourCycle: "h23" };
default:
return {};
}
}
/** Split a local `yyyy-MM-ddTHH:mm` string into its calendar parts. */
function parseLocalDateTime(value: string): {
date: string;
year: number;
month: number;
day: number;
hour: number;
minute: number;
} | null {
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value.trim());
if (!match) return null;
const [, y, mo, d, h, mi] = match;
const year = Number(y);
const month = Number(mo);
const day = Number(d);
const hour = Number(h);
const minute = Number(mi);
// Reject impossible component ranges so the field falls back to placeholder
// rather than rendering an "Invalid Date".
if (
month < 1 ||
month > 12 ||
day < 1 ||
day > 31 ||
hour > 23 ||
minute > 59
) {
return null;
}
return { date: `${y}-${mo}-${d}`, year, month, day, hour, minute };
}
/** Placeholder that mirrors the active date order, e.g. "DD.MM.YYYY --:--". */
function formatPlaceholder(
dateFormat: ReturnType<typeof useDateFormatPreference>,
locale: ReturnType<typeof useTranslations>["locale"],
): string {
const date = formatDate("2026-12-31", dateFormat, locale)
.replace(/31/g, "DD")
.replace(/12/g, "MM")
.replace(/2026/g, "YYYY");
return `${date} --:--`;
}
+5 -5
View File
@@ -4,7 +4,7 @@ import * as React from "react";
import { ListFilter, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { DateInput } from "@/components/ui/date-input";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@@ -266,12 +266,12 @@ export function FilterBarDateRange({
>
{t("dataList.dateFrom")}
</Label>
<DateInput
<DateField
id={`${idPrefix}-from`}
className="w-44"
value={from}
max={to || undefined}
onChange={(e) => onFromChange(e.target.value)}
onChange={onFromChange}
/>
</div>
<div className="flex flex-col gap-1">
@@ -281,12 +281,12 @@ export function FilterBarDateRange({
>
{t("dataList.dateTo")}
</Label>
<DateInput
<DateField
id={`${idPrefix}-to`}
className="w-44"
value={to}
min={from || undefined}
onChange={(e) => onToChange(e.target.value)}
onChange={onToChange}
/>
</div>
</PopoverContent>
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { ExternalLink } from "lucide-react";
import { learnLinkForMetric } from "@/lib/learn-links";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* v1.21.0 — discreet "Learn more" pointer to a public /learn guide.
*
* One small, calm anchor (no new colour, no card chrome) that links a concept
* out to its plain-language guide. Fail-closed by construction:
*
* - the href is resolved through `learnLinkForMetric` — a closed-set,
* registry-backed URL, never user/model content — so there is no XSS
* surface and no invented link;
* - an unmapped concept resolves to `null` and the component renders
* nothing, so a surface that wires a concept with no guide simply shows
* no pointer rather than a broken one.
*
* It is a literal `<a>` (no markdown library): `target="_blank"` with
* `rel="noopener noreferrer"`, the guide title as the accessible label.
*/
export interface LearnMoreLinkProps {
/**
* A `LearnConcept` key (or any metric id) to resolve. Anything unmapped
* renders nothing.
*/
concept: string;
className?: string;
}
export function LearnMoreLink({ concept, className }: LearnMoreLinkProps) {
const { t } = useTranslations();
const guide = learnLinkForMetric(concept);
if (guide == null) return null;
const label = t("common.learnMore");
return (
<a
href={guide.url}
target="_blank"
rel="noopener noreferrer"
data-slot="learn-more-link"
aria-label={`${label}: ${guide.title}`}
className={cn(
"text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs underline-offset-2 transition-colors hover:underline",
className,
)}
>
{label}
<ExternalLink className="h-3 w-3 shrink-0" aria-hidden="true" />
</a>
);
}
+23 -1
View File
@@ -7,8 +7,12 @@ import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiFetchRaw } from "@/lib/api/api-fetch";
import { retryOnceOnTransientError } from "@/lib/queries/retry-transient";
import { useTranslations } from "@/lib/i18n/context";
import type { TimeFormatPreference } from "@/lib/format-locale";
import type {
TimeFormatPreference,
DateFormatPreference,
} from "@/lib/format-locale";
import { isTimeFormatPreference, storeTimeFormat } from "@/lib/time-format";
import { isDateFormatPreference, storeDateFormat } from "@/lib/date-format";
import type { ModuleKey } from "@/lib/modules/registry";
import type { TourProgress } from "@/lib/onboarding/tour-progress";
import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister";
@@ -75,6 +79,14 @@ export interface AuthUser {
* "AUTO" against a stale /me payload.
*/
timeFormat: TimeFormatPreference;
/**
* Date-order display preference. AUTO follows the locale convention
* (de → dd.MM.yyyy, en → MM/dd/yyyy); DMY / MDY / YMD pin the field order
* regardless of locale. Mirrored into localStorage by `fetchMe` so
* `useFormatters()` and the `<DateField>` primitive render the same order.
* Coerced to "AUTO" against a stale /me payload.
*/
dateFormat: DateFormatPreference;
/**
* v1.4.47 W3 — per-user Coach opt-out. When `true`, every Coach
* mount point (`<LayoutCoachFab>`, `<LayoutCoachMount>`, the
@@ -157,6 +169,15 @@ async function fetchMe(): Promise<AuthUser> {
? data.timeFormat
: "AUTO";
storeTimeFormat(timeFormat);
// Date-order preference: coerce against a stale /me payload, then mirror
// into localStorage so `useFormatters()` and the `<DateField>` primitive
// (which cannot reach the query cache) render the same field order.
const dateFormat: DateFormatPreference = isDateFormatPreference(
data.dateFormat,
)
? data.dateFormat
: "AUTO";
storeDateFormat(dateFormat);
return {
...(data as AuthUser),
disableCoach: data.disableCoach ?? false,
@@ -167,6 +188,7 @@ async function fetchMe(): Promise<AuthUser> {
// without the field) so the display defaults to metric.
unitPreference: data.unitPreference === "imperial" ? "imperial" : "metric",
timeFormat,
dateFormat,
// v1.15.0 — coerce against a stale /me payload so the cycle nav entry
// stays hidden by default when the field is absent.
cycleTrackingEnabled: data.cycleTrackingEnabled === true,
+69
View File
@@ -0,0 +1,69 @@
/**
* D3-H1 coverage — the clinical safety floors are ONE source of truth.
*
* Before v1.21.0 the "is this BP a crisis?" floors lived in three modules with
* a divergent diastolic value (notification engine + Coach 180/120, dashboard
* hero 180/110), so a 170/112 reading lit the hero banner yet never tripped the
* alarm. This suite pins that every consumer now resolves the SAME canonical
* constants from `@/lib/clinical-floors`, so the three surfaces can never drift
* apart again without a test failure.
*/
import { describe, it, expect } from "vitest";
import {
BP_SYS_CRITICAL,
BP_DIA_CRITICAL,
BP_SYS_HYPOTENSIVE_FLOOR,
GLUCOSE_HYPO_FLOOR,
GLUCOSE_HYPO_SEVERE_FLOOR,
GLUCOSE_HYPER_FLOOR,
FEVER_BAND_C,
FEVER_RED_FLAG_C,
SPO2_RED_FLAG_PCT,
} from "@/lib/clinical-floors";
import {
BP_SYS_HYPERTENSIVE,
BP_DIA_HYPERTENSIVE,
BP_SYS_HYPOTENSIVE,
GLUCOSE_HYPO,
GLUCOSE_HYPO_SEVERE,
GLUCOSE_HYPER,
} from "@/lib/illness/safety-floors";
import { getMetricStatusMeta } from "@/lib/insights/metric-status-registry";
describe("clinical-floors — single source of truth", () => {
it("uses the guideline-correct hypertensive-crisis floors (180/120)", () => {
// ACC/AHA hypertensive-urgency floor: the diastolic floor is 120, NOT the
// former hero-only 110 that fired the banner on calmer readings.
expect(BP_SYS_CRITICAL).toBe(180);
expect(BP_DIA_CRITICAL).toBe(120);
});
it("safety-floors notification engine re-exports the canonical constants", () => {
expect(BP_SYS_HYPERTENSIVE).toBe(BP_SYS_CRITICAL);
expect(BP_DIA_HYPERTENSIVE).toBe(BP_DIA_CRITICAL);
expect(BP_SYS_HYPOTENSIVE).toBe(BP_SYS_HYPOTENSIVE_FLOOR);
expect(GLUCOSE_HYPO).toBe(GLUCOSE_HYPO_FLOOR);
expect(GLUCOSE_HYPO_SEVERE).toBe(GLUCOSE_HYPO_SEVERE_FLOOR);
expect(GLUCOSE_HYPER).toBe(GLUCOSE_HYPER_FLOOR);
});
it("the status registry's fever band binds to the canonical FEVER_BAND_C", () => {
expect(getMetricStatusMeta("BODY_TEMPERATURE")?.feverBandC).toBe(
FEVER_BAND_C,
);
});
it("the single-reading fever band sits below the sustained-fever escalation", () => {
// Two intentional lines for two questions — but both from one module, so
// they are visibly a pair (D3-L1), not unrelated magic numbers.
expect(FEVER_BAND_C).toBeLessThan(FEVER_RED_FLAG_C);
});
it("keeps the documented glucose + SpO2 floors stable", () => {
expect(GLUCOSE_HYPO_FLOOR).toBe(70);
expect(GLUCOSE_HYPO_SEVERE_FLOOR).toBe(54);
expect(GLUCOSE_HYPER_FLOOR).toBe(250);
expect(SPO2_RED_FLAG_PCT).toBe(92);
});
});
+110
View File
@@ -0,0 +1,110 @@
/**
* Date-order preference resolver + formatter (v1.21.0).
*
* `formatDate` and `resolveDateLocale` are the single source of truth for the
* field order the `<DateField>` primitive and `useFormatters().date()` render.
* AUTO defers to the active locale; DMY / MDY / YMD pin the order regardless.
*/
import { describe, expect, it } from "vitest";
import {
formatDate,
parseIsoDate,
resolveDateLocale,
isDateFormatPreference,
DATE_FORMAT_PREFERENCES,
DATE_FORMAT_OPTIONS,
} from "../date-format";
const ISO = "2026-02-19";
describe("resolveDateLocale", () => {
it("AUTO follows the active locale", () => {
expect(resolveDateLocale("AUTO", "de")).toBe("de-DE");
expect(resolveDateLocale("AUTO", "en")).toBe("en-US");
expect(resolveDateLocale("AUTO", "fr")).toBe("fr-FR");
});
it("DMY / MDY / YMD pin a canonical order locale regardless of UI locale", () => {
expect(resolveDateLocale("DMY", "en")).toBe("de-DE");
expect(resolveDateLocale("MDY", "de")).toBe("en-US");
expect(resolveDateLocale("YMD", "de")).toBe("en-CA");
});
});
describe("formatDate", () => {
it("renders DMY as dd.MM.yyyy", () => {
expect(formatDate(ISO, "DMY", "en")).toBe("19.02.2026");
});
it("renders MDY as MM/dd/yyyy", () => {
expect(formatDate(ISO, "MDY", "de")).toBe("02/19/2026");
});
it("renders YMD as yyyy-MM-dd (ISO)", () => {
expect(formatDate(ISO, "YMD", "de")).toBe("2026-02-19");
});
it("AUTO follows the locale convention", () => {
// de → dd.MM.yyyy, en → MM/dd/yyyy
expect(formatDate(ISO, "AUTO", "de")).toBe("19.02.2026");
expect(formatDate(ISO, "AUTO", "en")).toBe("02/19/2026");
});
it("accepts a Date and an ISO string identically", () => {
const d = new Date(Date.UTC(2026, 1, 19));
expect(formatDate(d, "DMY", "en")).toBe(formatDate(ISO, "DMY", "en"));
});
it("does not drift across a timezone boundary (UTC calendar date)", () => {
// A bare yyyy-MM-dd is a calendar date; it must render the same day in
// every order, never the day before/after.
expect(formatDate("2026-01-01", "DMY", "en")).toBe("01.01.2026");
expect(formatDate("2026-12-31", "YMD", "en")).toBe("2026-12-31");
});
it("returns an empty string for empty / unparseable input", () => {
expect(formatDate("", "AUTO", "en")).toBe("");
expect(formatDate(null, "AUTO", "en")).toBe("");
expect(formatDate(undefined, "AUTO", "en")).toBe("");
expect(formatDate("not-a-date", "AUTO", "en")).toBe("");
});
});
describe("parseIsoDate", () => {
it("parses a valid ISO calendar date to a UTC midnight Date", () => {
const d = parseIsoDate("2026-02-19");
expect(d).not.toBeNull();
expect(d!.getUTCFullYear()).toBe(2026);
expect(d!.getUTCMonth()).toBe(1);
expect(d!.getUTCDate()).toBe(19);
});
it("rejects a non-ISO shape and an impossible date", () => {
expect(parseIsoDate("19.02.2026")).toBeNull();
expect(parseIsoDate("2026-02-31")).toBeNull();
expect(parseIsoDate("2026-13-01")).toBeNull();
});
});
describe("preference list", () => {
it("isDateFormatPreference is a strict guard", () => {
expect(isDateFormatPreference("AUTO")).toBe(true);
expect(isDateFormatPreference("DMY")).toBe(true);
expect(isDateFormatPreference("XYZ")).toBe(false);
expect(isDateFormatPreference(null)).toBe(false);
});
it("exposes all four preferences and matching options", () => {
expect(DATE_FORMAT_PREFERENCES).toEqual(["AUTO", "DMY", "MDY", "YMD"]);
expect(DATE_FORMAT_OPTIONS.map((o) => o.value)).toEqual([
"AUTO",
"DMY",
"MDY",
"YMD",
]);
for (const opt of DATE_FORMAT_OPTIONS) {
expect(opt.labelKey).toMatch(/^settings\.dateFormat\./);
}
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect } from "vitest";
import { LEARN_GUIDES } from "@/lib/ai/coach/learn-catalog";
import { LEARN_LINKS, learnUrl, learnLinkForMetric } from "../learn-links";
const CATALOG_SLUGS = new Set(LEARN_GUIDES.map((g) => g.slug));
describe("learn-links registry", () => {
it("every mapped concept points at a slug that exists in the catalog", () => {
for (const [concept, slug] of Object.entries(LEARN_LINKS)) {
expect(CATALOG_SLUGS.has(slug), `${concept} → ${slug}`).toBe(true);
}
});
it("learnUrl mints the catalog URL for a known slug and null otherwise", () => {
const sample = LEARN_GUIDES[0]!;
expect(learnUrl(sample.slug)).toBe(sample.url);
expect(learnUrl(sample.slug)).toMatch(/^https:\/\/healthlog\.dev\/learn\//);
expect(learnUrl("not-a-real-slug")).toBeNull();
expect(learnUrl("")).toBeNull();
});
it("learnLinkForMetric resolves a mapped metric to its guide", () => {
const guide = learnLinkForMetric("RESTING_HEART_RATE");
expect(guide).not.toBeNull();
expect(guide?.slug).toBe("resting-heart-rate");
expect(guide?.url).toBe(learnUrl("resting-heart-rate"));
});
it("learnLinkForMetric fails closed for an unmapped id", () => {
expect(learnLinkForMetric("TOTALLY_UNKNOWN_METRIC")).toBeNull();
expect(learnLinkForMetric("")).toBeNull();
});
it("learnUrl is the only path — every guide URL stays on the public learn base", () => {
for (const g of LEARN_GUIDES) {
expect(learnUrl(g.slug)).toBe(g.url);
expect(g.url).toMatch(/^https:\/\/healthlog\.dev\/learn\/[a-z0-9-]+$/);
}
});
});
+139 -2
View File
@@ -1,7 +1,12 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { HttpError } from "@/lib/api-handler";
import { buildDateKey, MAX_TOKENS_PER_USER_PER_DAY } from "../budget";
import {
buildDateKey,
OPERATOR_COST_CAP,
USER_PLAN_CAP,
resolveDailyCap,
} from "../budget";
vi.mock("@/lib/db", () => ({
prisma: {
@@ -9,6 +14,8 @@ vi.mock("@/lib/db", () => ({
findUnique: vi.fn(),
upsert: vi.fn(),
},
$executeRaw: vi.fn(),
$queryRaw: vi.fn(),
},
}));
@@ -68,7 +75,7 @@ describe("budget", () => {
it("over-budget throws HttpError(429)", async () => {
prismaMock.coachUsage.findUnique.mockResolvedValue({
totalTokens: MAX_TOKENS_PER_USER_PER_DAY,
totalTokens: OPERATOR_COST_CAP,
});
const { enforceBudget } = await import("../budget");
await expect(enforceBudget("user-1", "2026-05-10")).rejects.toMatchObject({
@@ -105,3 +112,133 @@ describe("budget", () => {
expect(args.create.totalTokens).toBe(12);
});
});
describe("resolveDailyCap (F1 — provider-aware cap)", () => {
it("applies the operator-cost cap to an operator-key (admin-openai) primary", () => {
expect(resolveDailyCap([{ providerType: "admin-openai" }])).toBe(
OPERATOR_COST_CAP,
);
});
it("applies the generous user-plan cap to a ChatGPT-OAuth (codex) primary", () => {
expect(resolveDailyCap([{ providerType: "codex" }])).toBe(USER_PLAN_CAP);
});
it("applies the user-plan cap to BYOK openai / anthropic / local primaries", () => {
expect(resolveDailyCap([{ providerType: "openai" }])).toBe(USER_PLAN_CAP);
expect(resolveDailyCap([{ providerType: "anthropic" }])).toBe(
USER_PLAN_CAP,
);
expect(resolveDailyCap([{ providerType: "local" }])).toBe(USER_PLAN_CAP);
});
it("classifies on the PRIMARY entry — a user-egress chain with an admin-openai fallback stays user-plan", () => {
expect(
resolveDailyCap([
{ providerType: "codex" },
{ providerType: "admin-openai" },
]),
).toBe(USER_PLAN_CAP);
});
it("defaults an empty chain to the conservative operator cap", () => {
expect(resolveDailyCap([])).toBe(OPERATOR_COST_CAP);
});
});
describe("reserveBudget cap (F1 — user-plan path not locked out)", () => {
let prismaMock: {
$queryRaw: ReturnType<typeof vi.fn>;
$executeRaw: ReturnType<typeof vi.fn>;
};
beforeEach(async () => {
const dbModule = await import("@/lib/db");
prismaMock = dbModule.prisma as unknown as typeof prismaMock;
prismaMock.$queryRaw.mockReset();
prismaMock.$executeRaw.mockReset();
});
it("a user-plan chain does NOT trip after a spend that exceeds the operator cap", async () => {
// Prior spend well past the 200k operator cap, but under the user-plan cap.
const priorSpend = OPERATOR_COST_CAP + 50_000;
prismaMock.$queryRaw.mockResolvedValue([
{ total_tokens: priorSpend + 1_200 },
]);
const { reserveBudget } = await import("../budget");
const res = await reserveBudget(
"u",
1_200,
"2026-05-10",
resolveDailyCap([{ providerType: "codex" }]),
);
expect(res.allowed).toBe(true);
// The reservation upsert ran; no refund executeRaw fired.
expect(prismaMock.$executeRaw).not.toHaveBeenCalled();
});
it("the operator-key path STILL trips once prior spend reaches the operator cap", async () => {
const priorSpend = OPERATOR_COST_CAP;
prismaMock.$queryRaw.mockResolvedValue([
{ total_tokens: priorSpend + 1_200 },
]);
prismaMock.$executeRaw.mockResolvedValue(0);
const { reserveBudget } = await import("../budget");
const res = await reserveBudget(
"u",
1_200,
"2026-05-10",
resolveDailyCap([{ providerType: "admin-openai" }]),
);
expect(res.allowed).toBe(false);
// Refund of the reservation fired on refusal.
expect(prismaMock.$executeRaw).toHaveBeenCalled();
});
});
describe("reconcileSpend cached-token subtraction (F3)", () => {
let prismaMock: { $executeRaw: ReturnType<typeof vi.fn> };
beforeEach(async () => {
const dbModule = await import("@/lib/db");
prismaMock = dbModule.prisma as unknown as typeof prismaMock;
prismaMock.$executeRaw.mockReset();
prismaMock.$executeRaw.mockResolvedValue(0);
});
it("bills total_tokens minus cached input as the signed delta", async () => {
const { reconcileSpend } = await import("../budget");
// reserved 1200, gross 20000, cached 13000 → net actual 7000 → delta 5800.
await reconcileSpend("u", 1_200, 20_000, "2026-05-10", 13_000);
expect(prismaMock.$executeRaw).toHaveBeenCalledTimes(1);
// The tagged-template interpolations carry the delta; assert it is 5800.
const interpolations = prismaMock.$executeRaw.mock.calls[0].slice(1);
expect(interpolations).toContain(5_800);
});
it("clamps a cached count larger than gross to a zero charge (delta = -reserved)", async () => {
const { reconcileSpend } = await import("../budget");
await reconcileSpend("u", 1_200, 5_000, "2026-05-10", 9_999);
const interpolations = prismaMock.$executeRaw.mock.calls[0].slice(1);
// net actual clamped to 0 → delta = 0 - 1200 = -1200.
expect(interpolations).toContain(-1_200);
});
it("defaults cachedTokens to 0 (back-compat) — bills gross", async () => {
const { reconcileSpend } = await import("../budget");
await reconcileSpend("u", 1_000, 4_000, "2026-05-10");
const interpolations = prismaMock.$executeRaw.mock.calls[0].slice(1);
expect(interpolations).toContain(3_000);
});
});
describe("OPERATOR_COST_CAP (F2 — reasoning-aware operator cap)", () => {
it("is sized for reasoning turns, not a single non-reasoning reply", () => {
// Was 25_000 (≈ one gpt-5.x reasoning turn). Raised so the operator-key
// path survives a normal day of reasoning turns.
expect(OPERATOR_COST_CAP).toBeGreaterThanOrEqual(150_000);
// The user-plan cap is far more generous — a user's own egress is never
// gated on the operator-cost ceiling.
expect(USER_PLAN_CAP).toBeGreaterThan(OPERATOR_COST_CAP);
});
});
@@ -0,0 +1,89 @@
/**
* v1.21.0 (P6 / C2-5) — post-hoc numeric verifier on Coach prose. It must flag
* a number the model cited that no tool returned this turn (transcription
* drift), exempt structural window/ordinal integers, and no-op when no tool
* returned figures. Soft-strip replaces only the flagged tokens.
*/
import { describe, expect, it } from "vitest";
import {
collectNumericLeaves,
findUnverifiedCoachNumbers,
stripUnverifiedNumbers,
} from "@/lib/ai/coach/coach-prose-grounding";
describe("collectNumericLeaves", () => {
it("walks numbers, numeric strings, arrays and nested objects", () => {
const out = new Set<number>();
collectNumericLeaves(
{
section: { avgSys30: 128, label: "LDL", value: "120" },
recent: [{ v: 72 }, { v: "1.2" }],
},
out,
);
expect(out.has(128)).toBe(true);
expect(out.has(120)).toBe(true);
expect(out.has(72)).toBe(true);
expect(out.has(1.2)).toBe(true);
});
});
describe("findUnverifiedCoachNumbers", () => {
const payloads = [{ aggregate: { avgSys30: 128, avgDia30: 82 } }];
it("returns nothing when every cited number is grounded", () => {
const prose =
"Your systolic averaged 128 and diastolic 82 over the last 30 days.";
expect(findUnverifiedCoachNumbers(prose, payloads)).toEqual([]);
});
it("flags a drifted number the tools never returned", () => {
const prose = "Your systolic averaged about 138 recently.";
const findings = findUnverifiedCoachNumbers(prose, payloads);
expect(findings).toHaveLength(1);
expect(findings[0].value).toBe(138);
});
it("allows a rounding within tolerance", () => {
const prose = "Diastolic sat around 82.0 this period.";
expect(findUnverifiedCoachNumbers(prose, payloads)).toEqual([]);
});
it("exempts structural window + small-ordinal integers", () => {
const prose = "Over the last 30 days, 2 readings stood out of 7 total.";
// 30 + 7 are window integers; 2 is a small ordinal — none graded.
const findings = findUnverifiedCoachNumbers(prose, [{ x: 999 }]);
expect(findings).toEqual([]);
});
it("no-ops when no tool returned any figure", () => {
const prose = "Your systolic averaged 138 recently.";
expect(findUnverifiedCoachNumbers(prose, [])).toEqual([]);
expect(
findUnverifiedCoachNumbers(prose, [{ note: "no numbers here" }]),
).toEqual([]);
});
it("returns empty on empty prose", () => {
expect(findUnverifiedCoachNumbers("", payloads)).toEqual([]);
});
});
describe("stripUnverifiedNumbers", () => {
it("replaces only the flagged tokens, leaving grounded numbers intact", () => {
const prose = "Systolic 128, but it spiked to 138 yesterday.";
const findings = [{ value: 138, source: "138" }];
const { prose: out, stripped } = stripUnverifiedNumbers(prose, findings);
expect(stripped).toBe(1);
expect(out).toContain("128");
expect(out).toContain("[unverified]");
expect(out).not.toContain("138");
});
it("is a no-op when nothing was flagged", () => {
const { prose, stripped } = stripUnverifiedNumbers("all good", []);
expect(prose).toBe("all good");
expect(stripped).toBe(0);
});
});
@@ -0,0 +1,256 @@
/**
* v1.21.0 (C3) — the Coach correlations reader surfaces the deterministic FDR
* discovery + coincident-deviation flag, returns descriptive driver rows, and
* degrades to a clean { present: false } when nothing survives or a read fails.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
const measurementFindMany = vi.fn();
const moodFindMany = vi.fn();
const userFindUnique = vi.fn();
const medicationFindMany = vi.fn();
const intakeEventFindMany = vi.fn();
const illnessEpisodeFindMany = vi.fn();
const illnessDayLogFindMany = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: (a: unknown) => measurementFindMany(a) },
moodEntry: { findMany: (a: unknown) => moodFindMany(a) },
user: { findUnique: (a: unknown) => userFindUnique(a) },
medication: { findMany: (a: unknown) => medicationFindMany(a) },
medicationIntakeEvent: { findMany: (a: unknown) => intakeEventFindMany(a) },
illnessEpisode: { findMany: (a: unknown) => illnessEpisodeFindMany(a) },
illnessDayLog: { findMany: (a: unknown) => illnessDayLogFindMany(a) },
},
}));
const loadBaselineProfile = vi.fn();
const computeCoincidentDeviation = vi.fn();
vi.mock("@/lib/insights/derived", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/insights/derived")>();
return {
...actual,
loadBaselineProfile: () => loadBaselineProfile(),
computeCoincidentDeviation: () => computeCoincidentDeviation(),
};
});
const discoverCorrelations = vi.fn();
vi.mock("@/lib/insights/correlation-discovery", async (importOriginal) => {
const actual =
await importOriginal<
typeof import("@/lib/insights/correlation-discovery")
>();
return {
...actual,
discoverCorrelations: (...args: unknown[]) => discoverCorrelations(...args),
};
});
import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read";
describe("readCoachCorrelations", () => {
beforeEach(() => {
measurementFindMany.mockReset().mockResolvedValue([]);
moodFindMany.mockReset().mockResolvedValue([]);
userFindUnique.mockReset().mockResolvedValue({ timezone: "Europe/Berlin" });
medicationFindMany.mockReset().mockResolvedValue([]);
intakeEventFindMany.mockReset().mockResolvedValue([]);
illnessEpisodeFindMany.mockReset().mockResolvedValue([]);
illnessDayLogFindMany.mockReset().mockResolvedValue([]);
loadBaselineProfile
.mockReset()
.mockResolvedValue({ ageYears: 40, sex: "MALE", heightCm: 180 });
computeCoincidentDeviation.mockReset().mockResolvedValue(null);
discoverCorrelations.mockReset();
});
it("returns descriptive drivers when pairs survive", async () => {
discoverCorrelations.mockReturnValue({
discovered: [
{
behaviour: "TIME_IN_DAYLIGHT",
outcome: "SLEEP_DURATION",
n: 42,
r: 0.34,
pValue: 0.001,
qValue: 0.02,
interpretation:
"Higher time in daylight tends to go with higher next-day sleep duration in your data — a pattern worth watching, not a cause.",
lagDays: 1,
},
],
pairsTested: 18,
fdrQ: 0.1,
minPairs: 20,
});
const result = await readCoachCorrelations("u1");
expect(result.present).toBe(true);
expect(result.drivers).toHaveLength(1);
expect(result.drivers?.[0]).toMatchObject({
behaviour: "time in daylight",
outcome: "sleep duration",
direction: "higher",
lagDays: 1,
n: 42,
});
expect(result.pairsTested).toBe(18);
});
it("returns present:false when nothing survives and no coincident flag", async () => {
discoverCorrelations.mockReturnValue({
discovered: [],
pairsTested: 4,
fdrQ: 0.1,
minPairs: 20,
});
const result = await readCoachCorrelations("u1");
expect(result.present).toBe(false);
expect(result.reason).toBe("no_significant_pattern");
});
it("surfaces a fired coincident flag even without drivers", async () => {
discoverCorrelations.mockReturnValue({
discovered: [],
pairsTested: 4,
fdrQ: 0.1,
minPairs: 20,
});
computeCoincidentDeviation.mockResolvedValue({
status: "ok",
value: {
fired: true,
vitals: [],
contributing: [
{ type: "RESTING_HEART_RATE", direction: "above" },
{ type: "HEART_RATE_VARIABILITY", direction: "below" },
],
day: "2026-06-02",
illnessExplained: false,
},
coverage: {
requiredInputs: 2,
presentInputs: 2,
historyDays: 30,
missing: [],
},
confidence: { score: 80, band: "high" },
provenance: {
inputs: [],
source: "DAY",
windowDays: 30,
computedAt: "x",
},
});
const result = await readCoachCorrelations("u1");
expect(result.present).toBe(true);
expect(result.coincident?.fired).toBe(true);
expect(result.coincident?.contributing).toEqual([
{ metric: "resting heart rate", direction: "above" },
{ metric: "heart rate variability", direction: "below" },
]);
});
it("degrades to present:false on a read failure", async () => {
loadBaselineProfile.mockRejectedValue(new Error("db down"));
const result = await readCoachCorrelations("u1");
expect(result.present).toBe(false);
expect(result.reason).toBe("retrieval_failed");
});
// INTEGFIX — the measurement query must only ever carry real MeasurementType
// enum values; the three non-measurement channels (MOOD + the two new ones)
// are dropped, so the Postgres enum cast can never throw on a channel key.
it("never splices a non-measurement channel key into the type IN(...) filter", async () => {
discoverCorrelations.mockReturnValue({
discovered: [],
pairsTested: 0,
fdrQ: 0.1,
minPairs: 20,
});
await readCoachCorrelations("u1");
const where = measurementFindMany.mock.calls[0]?.[0]?.where as {
type: { in: string[] };
};
const types = where.type.in;
expect(types).not.toContain("MOOD");
expect(types).not.toContain("MEDICATION_COMPLIANCE");
expect(types).not.toContain("SYMPTOM_SEVERITY");
// The real enum channels are still present.
expect(types).toContain("SLEEP_DURATION");
});
// INTEGFIX — drive the REAL discovery engine end-to-end with illness data so
// the SYMPTOM_SEVERITY channel flows through the non-enum query path and
// surfaces as a descriptive driver (proving the reader no longer throws an
// enum-cast error on a user with symptom data).
it("surfaces a symptom-severity driver through the real engine without an enum-cast error", async () => {
const real = await vi.importActual<
typeof import("@/lib/insights/correlation-discovery")
>("@/lib/insights/correlation-discovery");
discoverCorrelations.mockImplementation((series) =>
real.discoverCorrelations(
series as Parameters<typeof real.discoverCorrelations>[0],
),
);
// 45 consecutive days. Symptom burden (behaviour, day D) drives next-day
// sleep (outcome, day D+1) with a strong, deterministic linear relationship
// so the pair clears n ≥ 20 / p < 0.05 / FDR / effect-size floor.
const DAYS = 45;
const base = Date.UTC(2026, 4, 1, 12, 0, 0); // midday → tz-stable day key
const dayKey = (offset: number): string => {
const d = new Date(base + offset * 86_400_000);
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
};
const dayLogs: Array<{
date: string;
functionalImpact: number;
symptomLinks: Array<{ severity: number }>;
}> = [];
const measurements: Array<{
type: string;
value: number;
measuredAt: Date;
}> = [];
for (let i = 0; i < DAYS; i++) {
const burden = i % 4; // 0..3, real 0↔>0 variance
dayLogs.push({
date: dayKey(i),
functionalImpact: burden,
symptomLinks: [],
});
// Next-day sleep falls as today's burden rises (8h baseline − burden).
measurements.push({
type: "SLEEP_DURATION",
value: 8 - burden,
measuredAt: new Date(base + (i + 1) * 86_400_000),
});
}
measurementFindMany.mockResolvedValue(measurements);
// One episode spanning the whole window so the builder zero-fills/overlays
// every logged day.
illnessEpisodeFindMany.mockResolvedValue([
{
id: "ep1",
onsetAt: new Date(base),
resolvedAt: new Date(base + DAYS * 86_400_000),
},
]);
illnessDayLogFindMany.mockResolvedValue(dayLogs);
const result = await readCoachCorrelations("u1");
expect(result.present).toBe(true);
const symptomDriver = result.drivers?.find(
(d) =>
d.behaviour === "symptom severity" || d.outcome === "symptom severity",
);
expect(symptomDriver).toBeDefined();
expect(symptomDriver?.outcome).toBe("sleep duration");
expect(symptomDriver?.n).toBeGreaterThanOrEqual(20);
});
});
@@ -0,0 +1,94 @@
/**
* RECON1 (D5-5) — the no-tools snapshot driver block surfaces the SAME gated,
* ranked drivers the get_correlations tool serves, bounded to the top N, and
* attaches nothing when no driver survives the quality gates.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
const readCoachCorrelations = vi.fn();
vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({
readCoachCorrelations: (userId: string) => readCoachCorrelations(userId),
}));
import {
buildCorrelationsSnapshotBlock,
SNAPSHOT_DRIVER_CAP,
} from "@/lib/ai/coach/correlations-snapshot";
import type { CoachCorrelationDriver } from "@/lib/ai/coach/tools/correlations-read";
function driver(
behaviour: string,
outcome: string,
r: number,
): CoachCorrelationDriver {
return {
behaviour,
outcome,
direction: r >= 0 ? "higher" : "lower",
lagDays: 1,
n: 42,
r,
note: `Higher ${behaviour} tends to go with ${outcome} — not a cause.`,
};
}
describe("buildCorrelationsSnapshotBlock (D5-5)", () => {
beforeEach(() => {
readCoachCorrelations.mockReset();
});
it("carries driver pairs when the engine surfaces them", async () => {
readCoachCorrelations.mockResolvedValue({
present: true,
drivers: [
driver("time in daylight", "sleep duration", 0.41),
driver("mood", "resting heart rate", -0.33),
],
pairsTested: 16,
windowDays: 180,
});
const block = await buildCorrelationsSnapshotBlock("user-1");
expect(block).not.toBeNull();
expect(block!.drivers).toHaveLength(2);
expect(block!.drivers[0].behaviour).toBe("time in daylight");
expect(block!.pairsTested).toBe(16);
expect(block!.windowDays).toBe(180);
});
it("bounds the block to the top N ranked drivers", async () => {
const many = Array.from({ length: SNAPSHOT_DRIVER_CAP + 4 }, (_, i) =>
driver(`behaviour ${i}`, "sleep duration", 0.5 - i * 0.02),
);
readCoachCorrelations.mockResolvedValue({
present: true,
drivers: many,
pairsTested: 30,
windowDays: 180,
});
const block = await buildCorrelationsSnapshotBlock("user-1");
expect(block!.drivers).toHaveLength(SNAPSHOT_DRIVER_CAP);
// Order preserved from the already-ranked tool output (highest effect first).
expect(block!.drivers[0].behaviour).toBe("behaviour 0");
});
it("attaches nothing when no driver survives the quality gates", async () => {
readCoachCorrelations.mockResolvedValue({
present: false,
reason: "no_significant_pattern",
});
expect(await buildCorrelationsSnapshotBlock("user-1")).toBeNull();
});
it("attaches nothing when present but the driver list is empty (coincident-only)", async () => {
// The reader can be present purely for the coincident flag with zero
// discovered drivers — the snapshot floor adds the driver layer only.
readCoachCorrelations.mockResolvedValue({
present: true,
drivers: [],
coincident: { fired: true, contributing: [], day: "2026-06-26" },
pairsTested: 12,
windowDays: 180,
});
expect(await buildCorrelationsSnapshotBlock("user-1")).toBeNull();
});
});
@@ -0,0 +1,180 @@
/**
* v1.21.0 (NEW-B B-2) — the Coach illness-scores block.
*
* The Coach illness CONTEXT block carries only labels + lifecycle + dates; this
* block surfaces the computed retrospective the illness card shows (recovery-
* gap, gap-driver, nadir, pre-onset, red flags) for the most relevant episode,
* read-only and coverage-gated. These guards assert the gating, the
* active-wins-over-resolved selection, the finding compaction, and that a
* withheld (insufficient) episode yields null — never a fabricated number.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
const isIllnessEnabled = vi.fn();
const computeEpisodeCorrelation = vi.fn();
const findUnique = vi.fn();
const findFirst = vi.fn();
vi.mock("@/lib/illness/gate", () => ({
isIllnessEnabled: (userId: string) => isIllnessEnabled(userId),
}));
vi.mock("@/lib/illness/correlation-read", () => ({
computeEpisodeCorrelation: (...args: unknown[]) =>
computeEpisodeCorrelation(...args),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: (a: unknown) => findUnique(a) },
illnessEpisode: { findFirst: (a: unknown) => findFirst(a) },
},
}));
import { buildIllnessScores } from "@/lib/ai/coach/illness-snapshot";
const ACTIVE = {
id: "ep-active",
label: "flu",
type: "INFECTION",
onsetAt: new Date("2026-06-10T00:00:00Z"),
resolvedAt: null,
lifecycle: "ACTIVE",
};
const RESOLVED = {
id: "ep-resolved",
label: "cold",
type: "INFECTION",
onsetAt: new Date("2026-05-01T00:00:00Z"),
resolvedAt: new Date("2026-05-08T00:00:00Z"),
lifecycle: "RESOLVED",
};
function okDerived() {
return {
status: "ok" as const,
value: {
episodeId: "ep-active",
recoveryGapDays: 4,
gapDriverType: "RESTING_HEART_RATE",
nadir: [
{
type: "HEART_RATE_VARIABILITY",
day: "2026-06-12",
value: 30,
baselineCenter: 55,
deviationSd: -2.43,
direction: "below" as const,
adverse: true,
},
{
type: "WEIGHT",
day: "2026-06-12",
value: 80,
baselineCenter: 81,
deviationSd: -0.5,
direction: "below" as const,
adverse: false,
},
],
preOnset: [],
redFlags: [
{
type: "BODY_TEMPERATURE",
reason: "sustained_fever" as const,
worstValue: 38.9,
days: 3,
},
],
},
};
}
describe("buildIllnessScores", () => {
beforeEach(() => {
isIllnessEnabled.mockReset();
computeEpisodeCorrelation.mockReset();
findUnique.mockReset();
findFirst.mockReset();
isIllnessEnabled.mockResolvedValue(true);
findUnique.mockResolvedValue({ timezone: "Europe/Berlin" });
});
it("returns null when the illness module is off (no read)", async () => {
isIllnessEnabled.mockResolvedValue(false);
const out = await buildIllnessScores("u1");
expect(out).toBeNull();
expect(findFirst).not.toHaveBeenCalled();
});
it("returns null when there is no episode to score", async () => {
findFirst.mockResolvedValue(null);
const out = await buildIllnessScores("u1");
expect(out).toBeNull();
expect(computeEpisodeCorrelation).not.toHaveBeenCalled();
});
it("prefers the active episode and surfaces the computed scores", async () => {
// First findFirst call = active, second = resolved. Active wins.
findFirst.mockResolvedValueOnce(ACTIVE).mockResolvedValueOnce(RESOLVED);
computeEpisodeCorrelation.mockResolvedValue(okDerived());
const out = await buildIllnessScores("u1");
expect(out).not.toBeNull();
// The active episode drove the read.
expect(computeEpisodeCorrelation).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ id: "ep-active" }),
"Europe/Berlin",
expect.any(Date),
);
expect(out).toMatchObject({
episodeLabel: "flu",
state: "active",
recoveryGapDays: 4,
gapDriverType: "RESTING_HEART_RATE",
});
// Red flags carried verbatim.
expect(out?.redFlags).toEqual([
{
type: "BODY_TEMPERATURE",
reason: "sustained_fever",
worstValue: 38.9,
days: 3,
},
]);
});
it("compacts nadir findings adverse-first and rounds the deviation", async () => {
findFirst.mockResolvedValueOnce(ACTIVE).mockResolvedValueOnce(null);
computeEpisodeCorrelation.mockResolvedValue(okDerived());
const out = await buildIllnessScores("u1");
// Adverse HRV finding leads the neutral WEIGHT one; deviation rounded to .1.
expect(out?.nadir[0]).toEqual({
type: "HEART_RATE_VARIABILITY",
day: "2026-06-12",
deviationSd: -2.4,
direction: "below",
});
});
it("falls back to the most-recently-resolved episode when none active", async () => {
findFirst.mockResolvedValueOnce(null).mockResolvedValueOnce(RESOLVED);
computeEpisodeCorrelation.mockResolvedValue({
...okDerived(),
value: { ...okDerived().value, episodeId: "ep-resolved" },
});
const out = await buildIllnessScores("u1");
expect(computeEpisodeCorrelation).toHaveBeenCalledWith(
"u1",
expect.objectContaining({ id: "ep-resolved" }),
"Europe/Berlin",
expect.any(Date),
);
expect(out?.state).toBe("resolved");
});
it("returns null when the engine withholds (insufficient coverage)", async () => {
findFirst.mockResolvedValueOnce(ACTIVE).mockResolvedValueOnce(null);
computeEpisodeCorrelation.mockResolvedValue({ status: "insufficient" });
const out = await buildIllnessScores("u1");
expect(out).toBeNull();
});
});
@@ -0,0 +1,63 @@
/**
* v1.21.0 (NEW-C C-3) — the Coach Learn-link post-filter. A published
* `/learn/<slug>` is kept verbatim; a fabricated one (a slug not in the
* catalog) is neutralised, so the catalog's "impossible by construction" claim
* becomes an enforced guarantee rather than a prompt promise.
*/
import { describe, expect, it } from "vitest";
import { scrubUnknownLearnLinks } from "@/lib/ai/coach/learn-link-guard";
import { LEARN_GUIDES } from "@/lib/ai/coach/learn-catalog";
const REAL = LEARN_GUIDES.find((g) => g.slug === "resting-heart-rate")!;
describe("scrubUnknownLearnLinks", () => {
it("returns the input unchanged when there is no /learn reference", () => {
const reply = "Your resting heart rate is settling nicely this week.";
const out = scrubUnknownLearnLinks(reply);
expect(out.text).toBe(reply);
expect(out.dropped).toEqual([]);
});
it("keeps a published /learn link verbatim (absolute URL)", () => {
const reply = `Worth a read — more on this: ${REAL.url}.`;
const out = scrubUnknownLearnLinks(reply);
expect(out.text).toContain(REAL.url);
expect(out.dropped).toEqual([]);
});
it("drops a fabricated /learn slug and reports it", () => {
const reply =
"That can drift with stress. More on this: https://healthlog.dev/learn/lower-your-cortisol-fast — give it a look.";
const out = scrubUnknownLearnLinks(reply);
expect(out.dropped).toContain("lower-your-cortisol-fast");
expect(out.text).not.toContain("lower-your-cortisol-fast");
expect(out.text).not.toContain("/learn/");
// The surrounding sentence survives.
expect(out.text).toContain("That can drift with stress");
expect(out.text).toContain("give it a look");
});
it("drops a fabricated relative /learn link", () => {
const reply = "See /learn/made-up-topic for the details.";
const out = scrubUnknownLearnLinks(reply);
expect(out.dropped).toContain("made-up-topic");
expect(out.text).not.toContain("/learn/");
});
it("keeps the real link and drops the fake one in the same reply", () => {
const reply = `Two reads: ${REAL.url} and https://healthlog.dev/learn/fictional-guide.`;
const out = scrubUnknownLearnLinks(reply);
expect(out.text).toContain(REAL.url);
expect(out.text).not.toContain("fictional-guide");
expect(out.dropped).toEqual(["fictional-guide"]);
});
it("is case-insensitive on the slug match", () => {
// A real slug in mixed case still resolves as published (kept).
const reply = "Read: https://healthlog.dev/learn/Resting-Heart-Rate.";
const out = scrubUnknownLearnLinks(reply);
expect(out.dropped).toEqual([]);
expect(out.text).toContain("Resting-Heart-Rate");
});
});
@@ -16,6 +16,19 @@ vi.mock("@/lib/ai/coach/snapshot", () => ({
buildCoachSnapshot(userId, scope),
}));
const readCoachCorrelations = vi.fn();
vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({
readCoachCorrelations: (userId: string) => readCoachCorrelations(userId),
}));
// v1.21.0 (NEW-B B-2) — the illness-recovery tool augments its snapshot blocks
// with the computed retrospective scores. Mock the read-only engine wrapper so
// the executor test stays a pure dispatch test.
const buildIllnessScores = vi.fn();
vi.mock("@/lib/ai/coach/illness-snapshot", () => ({
buildIllnessScores: (userId: string) => buildIllnessScores(userId),
}));
import { executeCoachTool } from "@/lib/ai/coach/tools/executor";
function snapshot(
@@ -33,6 +46,9 @@ function snapshot(
describe("executeCoachTool", () => {
beforeEach(() => {
buildCoachSnapshot.mockReset();
readCoachCorrelations.mockReset();
buildIllnessScores.mockReset();
buildIllnessScores.mockResolvedValue(null);
});
it("returns the matching section for get_metric_series when present", async () => {
@@ -182,6 +198,143 @@ describe("executeCoachTool", () => {
expect(miss.reason).toBe("analyte_not_found");
});
// v1.21.0 (NEW-A) — labs must reach the Coach with their value AND unit so it
// can cite the reading, not just name the biomarker. The snapshot labs block
// already carries { analyte, value, unit, rangeStatus, … }; the tool passes
// it through verbatim. This guards that the value + unit survive the tool.
it("get_labs carries each reading's value and unit (citation guarantee)", async () => {
buildCoachSnapshot.mockResolvedValue(
snapshot({
labs: {
recent: [
{
analyte: "LDL",
value: 120,
unit: "mg/dL",
rangeStatus: "above",
referenceLow: null,
referenceHigh: 116,
takenAt: "2026-06-01T00:00:00.000Z",
},
],
},
}),
);
const result = await executeCoachTool({
userId: "u1",
name: "get_labs",
rawArguments: "{}",
});
expect(result.present).toBe(true);
const data = result.data as {
recent: Array<{ value: number; unit: string; rangeStatus: string }>;
};
expect(data.recent[0]).toMatchObject({
analyte: "LDL",
value: 120,
unit: "mg/dL",
rangeStatus: "above",
});
});
it("returns the workouts section for get_workouts when present", async () => {
buildCoachSnapshot.mockResolvedValue(
snapshot({ workouts: { recent: [{ sport: "RUN" }], totalInWindow: 3 } }),
);
const result = await executeCoachTool({
userId: "u1",
name: "get_workouts",
rawArguments: JSON.stringify({ window: "last30days" }),
});
expect(result.present).toBe(true);
expect(result.data).toMatchObject({ totalInWindow: 3 });
});
it("returns { present: false } for get_workouts when no block", async () => {
buildCoachSnapshot.mockResolvedValue(snapshot({}));
const result = await executeCoachTool({
userId: "u1",
name: "get_workouts",
rawArguments: "{}",
});
expect(result.present).toBe(false);
expect(result.reason).toBe("no_data");
});
it("returns the cycle section for get_cycle when present", async () => {
buildCoachSnapshot.mockResolvedValue(
snapshot({ cycle: { phase: "luteal", dayOfCycle: 21 } }),
);
const result = await executeCoachTool({
userId: "u1",
name: "get_cycle",
rawArguments: "{}",
});
expect(result.present).toBe(true);
expect(result.data).toMatchObject({ phase: "luteal" });
});
it("returns { present: false } for get_cycle when cycle tracking is off", async () => {
// A non-cycle account produces no cycle block (gated in the builder).
buildCoachSnapshot.mockResolvedValue(snapshot({}));
const result = await executeCoachTool({
userId: "u1",
name: "get_cycle",
rawArguments: "{}",
});
expect(result.present).toBe(false);
expect(result.reason).toBe("no_data");
});
it("surfaces discovered drivers + coincident flag for get_correlations", async () => {
readCoachCorrelations.mockResolvedValue({
present: true,
drivers: [
{
behaviour: "time in daylight",
outcome: "sleep duration",
direction: "higher",
lagDays: 1,
n: 42,
r: 0.31,
note: "Higher time in daylight tends to go with higher next-day sleep duration in your data — a pattern worth watching, not a cause.",
},
],
coincident: {
fired: false,
contributing: [],
day: "2026-06-02",
illnessExplained: false,
},
pairsTested: 18,
windowDays: 180,
});
const result = await executeCoachTool({
userId: "u1",
name: "get_correlations",
rawArguments: "{}",
});
expect(result.present).toBe(true);
expect(result.data).toMatchObject({
drivers: [{ behaviour: "time in daylight", n: 42 }],
pairsTested: 18,
});
});
it("returns a clean { present: false } for get_correlations on no pattern", async () => {
readCoachCorrelations.mockResolvedValue({
present: false,
reason: "no_significant_pattern",
});
const result = await executeCoachTool({
userId: "u1",
name: "get_correlations",
rawArguments: "{}",
});
expect(result.present).toBe(false);
expect(result.reason).toBe("no_significant_pattern");
});
it("never passes userId as a tool argument (read from session only)", async () => {
buildCoachSnapshot.mockResolvedValue(snapshot({}));
// A crafted argument trying to smuggle a userId must be rejected by the
@@ -194,4 +347,154 @@ describe("executeCoachTool", () => {
expect(result.present).toBe(false);
expect(result.reason).toBe("invalid_arguments");
});
// v1.21.0 (NEW-B B-2) — get_illness_recovery now carries the computed
// retrospective scores the illness card shows (recovery-gap, gap-driver,
// nadir, pre-onset, red flags), not just labels + composites.
it("get_illness_recovery surfaces the computed illness scores", async () => {
buildCoachSnapshot.mockResolvedValue(
snapshot({ illness: { restMode: true, active: [], recentResolved: [] } }),
);
buildIllnessScores.mockResolvedValue({
episodeLabel: "flu",
episodeType: "INFECTION",
state: "resolved",
recoveryGapDays: 4,
gapDriverType: "RESTING_HEART_RATE",
nadir: [
{
type: "HEART_RATE_VARIABILITY",
day: "2026-06-10",
deviationSd: -2.4,
direction: "below",
},
],
preOnset: [],
redFlags: [
{
type: "BODY_TEMPERATURE",
reason: "sustained_fever",
worstValue: 38.9,
days: 3,
},
],
});
const result = await executeCoachTool({
userId: "u1",
name: "get_illness_recovery",
rawArguments: "{}",
});
expect(result.present).toBe(true);
const data = result.data as { illnessScores?: Record<string, unknown> };
expect(data.illnessScores).toMatchObject({
recoveryGapDays: 4,
gapDriverType: "RESTING_HEART_RATE",
});
expect(data.illnessScores?.redFlags).toHaveLength(1);
expect(data.illnessScores?.nadir).toHaveLength(1);
});
it("get_illness_recovery is present on scores alone (no derived blocks)", async () => {
// No illness/derived/strain/trajectory section, but the engine returned
// scores → the tool must still report present and carry them.
buildCoachSnapshot.mockResolvedValue(snapshot({}));
buildIllnessScores.mockResolvedValue({
episodeLabel: "cold",
episodeType: "INFECTION",
state: "active",
recoveryGapDays: null,
gapDriverType: null,
nadir: [],
preOnset: [],
redFlags: [],
});
const result = await executeCoachTool({
userId: "u1",
name: "get_illness_recovery",
rawArguments: "{}",
});
expect(result.present).toBe(true);
expect(
(result.data as { illnessScores?: unknown }).illnessScores,
).toBeTruthy();
});
});
// v1.21.0 (D5-1) — ONE snapshot per turn. When the route threads the shared
// full-source scope (the inventory's probe scope) and the tool's window
// matches, the tool must read under the SHARED scope key so every per-tool read
// lands the inventory's already-built 60s LRU entry — rather than rebuilding a
// distinct single-source snapshot per tool.
describe("executeCoachTool — shared snapshot scope (D5-1)", () => {
beforeEach(() => {
buildCoachSnapshot.mockReset();
});
const sharedScope = {
sources: ["bp", "hrv", "sleep", "glucose"] as never,
window: "last30days" as const,
};
it("reads under the shared scope when the tool window matches", async () => {
buildCoachSnapshot.mockResolvedValue(
snapshot({ bloodPressure: { aggregate: { avgSys30: 128 } } }),
);
const result = await executeCoachTool({
userId: "u1",
name: "get_metric_series",
rawArguments: JSON.stringify({ metric: "bp" }),
fallbackWindow: "last30days",
sharedScope,
});
expect(result.present).toBe(true);
// The read used the SHARED scope object, not a narrow single-source scope —
// so a second tool with the same shared scope hits the same cache key.
expect(buildCoachSnapshot).toHaveBeenCalledWith("u1", sharedScope);
});
it("two tools in a turn read under the SAME scope key (one build)", async () => {
buildCoachSnapshot.mockResolvedValue(
snapshot({
bloodPressure: { aggregate: {} },
heartRateVariability: { aggregate: {} },
}),
);
await executeCoachTool({
userId: "u1",
name: "get_metric_series",
rawArguments: JSON.stringify({ metric: "bp" }),
fallbackWindow: "last30days",
sharedScope,
});
await executeCoachTool({
userId: "u1",
name: "get_metric_series",
rawArguments: JSON.stringify({ metric: "hrv" }),
fallbackWindow: "last30days",
sharedScope,
});
// Both reads used the identical shared scope object → identical cache key.
const scopes = buildCoachSnapshot.mock.calls.map((c) => c[1]);
expect(scopes).toHaveLength(2);
expect(scopes[0]).toBe(sharedScope);
expect(scopes[1]).toBe(sharedScope);
});
it("falls back to a narrow scope when the tool overrides the window", async () => {
buildCoachSnapshot.mockResolvedValue(
snapshot({ bloodPressure: { aggregate: {} } }),
);
await executeCoachTool({
userId: "u1",
name: "get_metric_series",
// The model overrides the window → a distinct, correct build.
rawArguments: JSON.stringify({ metric: "bp", window: "last7days" }),
fallbackWindow: "last30days",
sharedScope,
});
expect(buildCoachSnapshot).toHaveBeenCalledWith("u1", {
sources: ["bp"],
window: "last7days",
});
});
});
@@ -21,6 +21,7 @@ vi.mock("@/lib/cycle/gate", () => ({
import {
buildCoachDataInventory,
renderDataInventory,
renderFocusHint,
} from "@/lib/ai/coach/tools/inventory";
function snapshot(
@@ -67,6 +68,50 @@ describe("buildCoachDataInventory", () => {
expect(sleep?.present).toBe(false);
});
it("advertises workouts + correlations tools and the cycle tool when enabled", async () => {
buildCoachSnapshot.mockResolvedValue(
snapshot(
{
workouts: { recent: [], totalInWindow: 4 },
mood: {},
cycle: { phase: "luteal" },
scope: { window: "last30days" },
},
{ workouts: 4 },
),
);
isCycleAvailableForUser.mockResolvedValue(true);
const inv = await buildCoachDataInventory("u1", undefined);
const workouts = inv.entries.find((e) => e.tool === "get_workouts");
expect(workouts).toMatchObject({ present: true, count: 4 });
const correlations = inv.entries.find((e) => e.tool === "get_correlations");
expect(correlations?.present).toBe(true); // mood present → correlatable
const cycle = inv.entries.find((e) => e.tool === "get_cycle");
expect(cycle).toMatchObject({ present: true });
});
it("omits the cycle line when cycle tracking is unavailable", async () => {
buildCoachSnapshot.mockResolvedValue(snapshot({ bloodPressure: {} }));
isCycleAvailableForUser.mockResolvedValue(false);
const inv = await buildCoachDataInventory("u1", undefined);
expect(inv.entries.find((e) => e.tool === "get_cycle")).toBeUndefined();
});
it("probes a wide source set so synced domains are advertised", async () => {
// body composition + spo2 present even though they are not default clusters.
buildCoachSnapshot.mockResolvedValue(
snapshot(
{ bodyFat: {}, oxygenSaturation: {}, scope: { window: "last30days" } },
{ body_fat: 12, spo2: 30 },
),
);
const inv = await buildCoachDataInventory("u1", undefined);
const bodyFat = inv.entries.find((e) => e.metric === "body_fat");
const spo2 = inv.entries.find((e) => e.metric === "spo2");
expect(bodyFat).toMatchObject({ present: true, count: 12 });
expect(spo2).toMatchObject({ present: true, count: 30 });
});
it("reports restMode + cycleEnabled", async () => {
buildCoachSnapshot.mockResolvedValue(
snapshot({ illness: { restMode: true } }),
@@ -98,3 +143,30 @@ describe("renderDataInventory", () => {
expect(text.toLowerCase()).not.toContain("whoop");
});
});
// v1.21.0 (D1) — the launch FOCUS hint narrows tool mode to the metric the
// Coach was opened from. The no-tools path narrows the snapshot; this is the
// tool-mode equivalent so the metric-narrowing is not silently dropped.
describe("renderFocusHint", () => {
it("is empty on a generic open (no pinned sources)", () => {
expect(renderFocusHint(undefined)).toBe("");
expect(renderFocusHint([])).toBe("");
});
it("names the launched domain(s) by their natural-language label", () => {
const hint = renderFocusHint(["hrv", "resting_hr", "sleep"]);
expect(hint).toContain("FOCUS:");
expect(hint).toContain("heart-rate variability");
expect(hint).toContain("resting heart rate");
expect(hint).toContain("sleep");
// It instructs prioritisation, not exclusion (the user can still pivot).
expect(hint).toContain("prioritise");
expect(hint).toContain("only branch to other domains");
});
it("falls back to the raw source key for an unlabelled source", () => {
// `compliance` has no domain label in the map; the raw key is used.
const hint = renderFocusHint(["compliance"]);
expect(hint).toContain("compliance");
});
});
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff. Show more