mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
173 files changed
+8983
-825
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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}$/);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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/*`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+17
-11
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} --:--`;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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\./);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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-]+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in new issue
Block a user