diff --git a/CHANGELOG.md b/CHANGELOG.md
index c3eaf3052..ba9d23030 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index e0dec07ce..5c035b17b 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -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
diff --git a/e2e/medications-wizard-daily.spec.ts b/e2e/medications-wizard-daily.spec.ts
index 65b23df62..7b62a9670 100644
--- a/e2e/medications-wizard-daily.spec.ts
+++ b/e2e/medications-wizard-daily.spec.ts
@@ -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}$/);
diff --git a/e2e/medications-wizard-oneshot.spec.ts b/e2e/medications-wizard-oneshot.spec.ts
index a5506caa3..9bb93e075 100644
--- a/e2e/medications-wizard-oneshot.spec.ts
+++ b/e2e/medications-wizard-oneshot.spec.ts
@@ -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
diff --git a/e2e/settings-mobile-consistency.spec.ts b/e2e/settings-mobile-consistency.spec.ts
index acc8ed7cc..a75a456d0 100644
--- a/e2e/settings-mobile-consistency.spec.ts
+++ b/e2e/settings-mobile-consistency.spec.ts
@@ -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 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
+ // , and the formatted overlay that fronts the
+ // sr-only native 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);
diff --git a/messages/de.json b/messages/de.json
index 933e6e1f5..9eb67bb5a 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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",
diff --git a/messages/en.json b/messages/en.json
index e7fe5729f..18535e2c4 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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",
diff --git a/messages/es.json b/messages/es.json
index 833990236..604ff582d 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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",
diff --git a/messages/fr.json b/messages/fr.json
index 8a3453623..22653496d 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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",
diff --git a/messages/it.json b/messages/it.json
index 734f6011a..4481f8870 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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",
diff --git a/messages/pl.json b/messages/pl.json
index b8a448c23..7f27207f1 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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",
diff --git a/package.json b/package.json
index 987fc9875..3fec1e7a8 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/prisma/migrations/0192_v1210_date_format/migration.sql b/prisma/migrations/0192_v1210_date_format/migration.sql
new file mode 100644
index 000000000..4cdf65164
--- /dev/null
+++ b/prisma/migrations/0192_v1210_date_format/migration.sql
@@ -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';
diff --git a/prisma/migrations/0193_v1210_rollup_x_rescale/migration.sql b/prisma/migrations/0193_v1210_rollup_x_rescale/migration.sql
new file mode 100644
index 000000000..945dea4f8
--- /dev/null
+++ b/prisma/migrations/0193_v1210_rollup_x_rescale/migration.sql
@@ -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;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index e41c0f57f..437c8ef3d 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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")
diff --git a/public/sw.js b/public/sw.js
index 603f8d944..b580222e5 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -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/*`
diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts
index 434f29221..4ef9ebe53 100644
--- a/src/app/api/auth/me/route.ts
+++ b/src/app/api/auth/me/route.ts
@@ -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 `.
*/
+
+/**
+ * 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 `
- {t("insights.coach.heroSubline")}
- ` / `
{marker.context}
) : null} +diff --git a/src/components/medications/scheduling/cadence-picker.tsx b/src/components/medications/scheduling/cadence-picker.tsx index aac443d72..4a4271e0b 100644 --- a/src/components/medications/scheduling/cadence-picker.tsx +++ b/src/components/medications/scheduling/cadence-picker.tsx @@ -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({ - onChange(e.target.value)} - className="h-11" + onChange={onChange} data-slot="cadence-yearly-date" /> diff --git a/src/components/medications/scheduling/course-window-row.tsx b/src/components/medications/scheduling/course-window-row.tsx index aaad105bd..4ec9f91c1 100644 --- a/src/components/medications/scheduling/course-window-row.tsx +++ b/src/components/medications/scheduling/course-window-row.tsx @@ -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({ - 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" /> @@ -152,17 +152,16 @@ export function CourseWindowRow({ - 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 && (
{t("medications.detail.zeitplan.history.fromLabel")}
-
@@ -416,6 +417,7 @@ export function AccountSection() {
+ {msg ?? t("settings.dateFormat.description")}
+