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 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 diff --git a/src/app/api/insights/chat/__tests__/route-module-gate.test.ts b/src/app/api/insights/chat/__tests__/route-module-gate.test.ts index 8b6c82fd3..7b8e75db2 100644 --- a/src/app/api/insights/chat/__tests__/route-module-gate.test.ts +++ b/src/app/api/insights/chat/__tests__/route-module-gate.test.ts @@ -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", () => ({ diff --git a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts index ef6332dde..eeaea14f3 100644 --- a/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts +++ b/src/app/api/insights/chat/__tests__/route-snapshot-once.test.ts @@ -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 })), diff --git a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts index d8b95d743..e09dc97a2 100644 --- a/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts +++ b/src/app/api/insights/chat/__tests__/route-tool-mode.test.ts @@ -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 }], - })), - })); +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: 2, + 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).mockReturnValue({ + prose: drift, + keyValues: [], + malformed: false, + malformedEntries: [], + }); + (parseSuggestReminder as ReturnType).mockReturnValue({ + prose: drift, + }); + // The tool returned systolic 128; the prose drifts to 138. + (runCoachToolLoop as ReturnType).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).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 { diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 341476274..27b46004f 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -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/`, but that is guidance, not + // enforcement: a fabricated `/learn/` 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({ diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index 76a9c6868..ea5d940a5 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -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).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).mockResolvedValue( + [], + ); + ( + prisma.medicationIntakeEvent.findMany as ReturnType + ).mockResolvedValue([]); + ( + prisma.illnessEpisode.findMany as ReturnType + ).mockResolvedValue([]); + (prisma.illnessDayLog.findMany as ReturnType).mockResolvedValue( + [], + ); }); const callGet = GET as unknown as (req: NextRequest) => Promise; diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index dea015144..e9b511687 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -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); - - // 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", + const now = new Date(); + const since = new Date(now.getTime() - WINDOW_DAYS * MS_PER_DAY); + + // 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, }, }); diff --git a/src/app/api/labs/ocr/extract/__tests__/route.test.ts b/src/app/api/labs/ocr/extract/__tests__/route.test.ts index 055a56a1f..f4617a05c 100644 --- a/src/app/api/labs/ocr/extract/__tests__/route.test.ts +++ b/src/app/api/labs/ocr/extract/__tests__/route.test.ts @@ -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( @@ -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, ); }); diff --git a/src/app/api/labs/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts index e35b710d6..ebddbf99c 100644 --- a/src/app/api/labs/ocr/extract/route.ts +++ b/src/app/api/labs/ocr/extract/route.ts @@ -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({ diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index 095ade7e3..d3c211840 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -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, diff --git a/src/components/dashboard/medication-intake-quick-add.tsx b/src/components/dashboard/medication-intake-quick-add.tsx index 25d7f1c4a..b3d0cd6fb 100644 --- a/src/components/dashboard/medication-intake-quick-add.tsx +++ b/src/components/dashboard/medication-intake-quick-add.tsx @@ -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({ - setTakenAt(e.target.value)} + onChange={setTakenAt} required aria-required="true" aria-invalid={!!error || undefined} diff --git a/src/components/illness/log-day-sheet.tsx b/src/components/illness/log-day-sheet.tsx index 19319ee94..1f6a740e8 100644 --- a/src/components/illness/log-day-sheet.tsx +++ b/src/components/illness/log-day-sheet.tsx @@ -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({ - { - if (e.target.value) setSelectedDate(e.target.value); + onChange={(value) => { + if (value) setSelectedDate(value); }} className="max-w-44" /> diff --git a/src/components/illness/new-episode-sheet.tsx b/src/components/illness/new-episode-sheet.tsx index a93d61480..0334548b3 100644 --- a/src/components/illness/new-episode-sheet.tsx +++ b/src/components/illness/new-episode-sheet.tsx @@ -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({
- setOnset(e.target.value)} + onChange={setOnset} className="max-w-44" />
diff --git a/src/components/insights/__tests__/recommendation-card-confidence.test.tsx b/src/components/insights/__tests__/recommendation-card-confidence.test.tsx index c45c48f1f..12f3edbaa 100644 --- a/src/components/insights/__tests__/recommendation-card-confidence.test.tsx +++ b/src/components/insights/__tests__/recommendation-card-confidence.test.tsx @@ -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 `` 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", () => ({ diff --git a/src/components/insights/__tests__/recommendation-card.test.tsx b/src/components/insights/__tests__/recommendation-card.test.tsx index 952f1068f..1fddb563d 100644 --- a/src/components/insights/__tests__/recommendation-card.test.tsx +++ b/src/components/insights/__tests__/recommendation-card.test.tsx @@ -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 `` 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", () => ({ diff --git a/src/components/insights/__tests__/recommendations-grid.test.tsx b/src/components/insights/__tests__/recommendations-grid.test.tsx index ce7400963..15dc74dcc 100644 --- a/src/components/insights/__tests__/recommendations-grid.test.tsx +++ b/src/components/insights/__tests__/recommendations-grid.test.tsx @@ -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 `` 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: () => ({ diff --git a/src/components/insights/ask-coach-action.tsx b/src/components/insights/ask-coach-action.tsx new file mode 100644 index 000000000..1200ab309 --- /dev/null +++ b/src/components/insights/ask-coach-action.tsx @@ -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 / : 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 ( + + ); +} diff --git a/src/components/insights/coach-metric-scope.ts b/src/components/insights/coach-metric-scope.ts new file mode 100644 index 000000000..317cb64a0 --- /dev/null +++ b/src/components/insights/coach-metric-scope.ts @@ -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: + * - `` 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.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 = { + 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; +} diff --git a/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx b/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx index c7bbc9049..e695b6148 100644 --- a/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx @@ -136,24 +136,30 @@ describe(" 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(, 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(/]*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( , makeClient(), ); expect(html).toContain('data-slot="coach-hero"'); - expect(html).toContain('data-slot="coach-page-conversations"'); + expect(html).toContain('data-slot="coach-page-settings"'); }); }); diff --git a/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx b/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx index 7c36b6892..4d8c717d8 100644 --- a/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-hero.test.tsx @@ -17,8 +17,10 @@ describe("", () => { composer} />, ); 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("", () => { it("renders the German greeting under the de locale", () => { const html = render(, "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", () => { diff --git a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx index 6f113d0c4..d54280561 100644 --- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx @@ -237,10 +237,11 @@ describe("", () => { 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( ", () => { ); 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( - /]*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", () => { diff --git a/src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts b/src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts new file mode 100644 index 000000000..0619ce106 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/coach-launch-scope.test.ts @@ -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(); + }); +}); diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index dd9356ed3..03b093499 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -618,7 +618,9 @@ describe("", () => { })} />, ); - 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. diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index add693bda..0e5004d52 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -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 { * `` (required for the dialog's accessible name) while * the page mounts a plain `

` / `

`. */ + +/** + * 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 ``, 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. */}

-
{heroActive ? ( diff --git a/src/components/insights/coach-panel/coach-drawer.tsx b/src/components/insights/coach-panel/coach-drawer.tsx index bcbfce7d3..06f2cf7bf 100644 --- a/src/components/insights/coach-panel/coach-drawer.tsx +++ b/src/components/insights/coach-panel/coach-drawer.tsx @@ -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) { - {/* 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. */}
{t("insights.coach.heroGreeting")}

-

- {t("insights.coach.heroSubline")} -

{/* Composer — the live , centred. */} diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index dc0b0e2a3..372682588 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -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({ ); + // 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 = ( + + + + + + onNewChat?.()} + > + + onOpenHistory?.()} + > + + + + ); + return (
{/* 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. */}
- {/* 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}