From a62e00cfcd299ed0a83588378f163633c7eb0254 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 11:45:58 +0200 Subject: [PATCH] feat(coach): a guided dialog for follow-ups and nudges that know their owner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open follow-up questions now run as a guided in-chat sequence — a quiet card offers to start, the coach asks one question at a time with visible progress, answers can be adopted into the matching self-context field, skipping keeps a question pending and a closing summary says what was taken over. Proactive nudges learn three new deterministic signals — weight drifting from its target band, a run of short nights, an active user falling silent — address the focus the user asked to watch, and gain per-group toggles and a frequency preference. --- e2e/coach-guided-questions.spec.ts | 151 +++++ messages/de.json | 74 ++- messages/en.json | 74 ++- messages/es.json | 74 ++- messages/fr.json | 74 ++- messages/it.json | 74 ++- messages/pl.json | 74 ++- .../__tests__/route.test.ts | 22 +- .../__tests__/guided-dialog.test.tsx | 133 ++++ .../guided-questions-machine.test.ts | 274 ++++++++ .../__tests__/message-thread.test.tsx | 129 +++- .../coach-panel/coach-conversation.tsx | 199 ++++-- .../insights/coach-panel/coach-input.tsx | 9 +- .../coach-panel/guided-dialog-bubbles.tsx | 241 +++++++ .../coach-panel/guided-questions-card.tsx | 112 ++++ .../coach-panel/guided-questions-machine.ts | 244 ++++++++ .../insights/coach-panel/message-thread.tsx | 133 +++- .../coach-panel/self-context-adopt-offer.tsx | 20 +- .../coach-panel/self-context-chips.tsx | 123 ---- src/components/settings/coach-nudge-card.tsx | 155 ++++- src/lib/jobs/__tests__/coach-nudge.test.ts | 360 ++++++++++- src/lib/jobs/coach-nudge.ts | 591 ++++++++++++++---- .../__tests__/notification-prefs.test.ts | 120 +++- src/lib/validations/notification-prefs.ts | 54 ++ src/lib/validations/schedule-revision.ts | 62 ++ 25 files changed, 3199 insertions(+), 377 deletions(-) create mode 100644 e2e/coach-guided-questions.spec.ts create mode 100644 src/components/insights/coach-panel/__tests__/guided-dialog.test.tsx create mode 100644 src/components/insights/coach-panel/__tests__/guided-questions-machine.test.ts create mode 100644 src/components/insights/coach-panel/guided-dialog-bubbles.tsx create mode 100644 src/components/insights/coach-panel/guided-questions-card.tsx create mode 100644 src/components/insights/coach-panel/guided-questions-machine.ts delete mode 100644 src/components/insights/coach-panel/self-context-chips.tsx create mode 100644 src/lib/validations/schedule-revision.ts diff --git a/e2e/coach-guided-questions.spec.ts b/e2e/coach-guided-questions.spec.ts new file mode 100644 index 000000000..77e388876 --- /dev/null +++ b/e2e/coach-guided-questions.spec.ts @@ -0,0 +1,151 @@ +import { expect, test } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * Guided clarifying-questions flow on the full-page Coach route + * (v1.16.5 — V2 of the v1.16.0 composer chips). + * + * Contracts under test: + * + * 1. With pending self-context questions, the entry card + * (`data-slot="coach-guided-offer"`) renders above the composer + * and offers start / later / don't-ask-again. + * 2. Starting the flow mounts the first deterministic question + * bubble in the thread (`data-slot="coach-guided-question"`, + * `data-state="current"`) with the progress line and the + * skip / later / dismiss actions. + * 3. Skip advances to the next question without any server write. + * 4. "Later" mid-sequence ends the flow without a summary when + * nothing was answered; the entry card stays hidden for the + * session. + * 5. The entry-card "later" hides the offer without touching the + * thread. + * + * The questions API is mocked — the flow itself is purely client-side + * until an answer is sent, and no answer is sent here (the chat SSE + * stream stays out of scope). + */ +test.describe("Coach guided clarifying questions", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + const QUESTIONS = [ + "Guided e2e question one?", + "Guided e2e question two?", + "Guided e2e question three?", + ]; + + test.beforeEach(async ({ page }) => { + // Empty conversation list so the page surface paints the empty + // thread + history rail without hitting the database. + // Wide pattern on purpose: the list GET may fire with or without a + // cursor query. Neither the detail GET nor the SSE POST fires in + // this spec (no conversation is opened, no message is sent). + await page.route("**/api/insights/chat*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { conversations: [], nextCursor: null }, + error: null, + }), + }), + ); + await page.route("**/api/insights/coach/nudge-status*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { nudgedAt: null, unread: false }, + error: null, + }), + }), + ); + // Pending questions: GET serves the snapshot, DELETE (dismiss one + // or all) empties it. + await page.route("**/api/coach/about-me/questions*", (route) => { + const isDelete = route.request().method() === "DELETE"; + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { questions: isDelete ? [] : QUESTIONS }, + error: null, + }), + }); + }); + }); + + test("offer card starts the guided sequence; skip and later walk it", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "flow logic is viewport-independent; desktop run suffices", + ); + + await page.goto("/insights/coach", { waitUntil: "domcontentloaded" }); + + // 1. Entry card with the three choices. + const offer = page.locator('[data-slot="coach-guided-offer"]'); + await expect(offer).toBeVisible({ timeout: 10_000 }); + await expect( + offer.locator('[data-slot="coach-guided-offer-start"]'), + ).toBeVisible(); + await expect( + offer.locator('[data-slot="coach-guided-offer-later"]'), + ).toBeVisible(); + await expect( + offer.locator('[data-slot="coach-guided-offer-dismiss"]'), + ).toBeVisible(); + + // 2. Start → first question bubble (typing reveal lasts <1s). + await offer.locator('[data-slot="coach-guided-offer-start"]').click(); + await expect(offer).toHaveCount(0); + const question = page.locator( + '[data-slot="coach-guided-question"][data-state="current"]', + ); + await expect(question).toBeVisible({ timeout: 10_000 }); + await expect(question).toContainText(QUESTIONS[0], { timeout: 10_000 }); + await expect( + question.locator('[data-slot="coach-guided-progress"]'), + ).toContainText("1"); + + // 3. Skip → second question, original numbering. + await question.locator('[data-slot="coach-guided-skip"]').click(); + await expect(question).toContainText(QUESTIONS[1], { timeout: 10_000 }); + await expect( + question.locator('[data-slot="coach-guided-progress"]'), + ).toContainText("2"); + + // 4. Later mid-sequence → flow ends, nothing answered → no summary, + // no question bubble, and the offer stays hidden for the session. + await question.locator('[data-slot="coach-guided-later"]').click(); + await expect( + page.locator('[data-slot="coach-guided-question"]'), + ).toHaveCount(0); + await expect( + page.locator('[data-slot="coach-guided-summary"]'), + ).toHaveCount(0); + await expect(offer).toHaveCount(0); + }); + + test("entry-card later hides the offer without starting the flow", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "flow logic is viewport-independent; desktop run suffices", + ); + + await page.goto("/insights/coach", { waitUntil: "domcontentloaded" }); + + const offer = page.locator('[data-slot="coach-guided-offer"]'); + await expect(offer).toBeVisible({ timeout: 10_000 }); + await offer.locator('[data-slot="coach-guided-offer-later"]').click(); + await expect(offer).toHaveCount(0); + await expect( + page.locator('[data-slot="coach-guided-question"]'), + ).toHaveCount(0); + }); +}); diff --git a/messages/de.json b/messages/de.json index 5131c9de3..f59833f71 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1599,6 +1599,36 @@ "graceEcho": "Erinnerungsfenster: {minutes} Minuten", "reminderTitle": "Erinnerung", "manageChannels": "Benachrichtigungskanäle verwalten", + "history": { + "title": "Planhistorie", + "currentSince": "Aktueller Plan seit {date}", + "noTimes": "Keine festen Uhrzeiten", + "manualChip": "Manuell ergänzt", + "showErasOne": "1 frühere Ära anzeigen", + "showErasOther": "{count} frühere Ären anzeigen", + "hideEras": "Frühere Ären ausblenden", + "addEra": "Frühere Ära ergänzen", + "addEraTitle": "Frühere Ära ergänzen", + "addEraDescription": "Halte fest, zu welchen Uhrzeiten dieses Medikament früher eingenommen wurde — etwa bevor du es hier angepasst hast.", + "timesLabel": "Einnahmezeiten", + "fromLabel": "Von", + "untilLabel": "Bis", + "untilHint": "„Bis\" ist der Tag, ab dem der nachfolgende Plan galt (ausschließlich).", + "validationTimes": "Mindestens eine Uhrzeit angeben.", + "validationRangeMissing": "Von- und Bis-Datum angeben.", + "validationRangeOrder": "Das Von-Datum muss vor dem Bis-Datum liegen.", + "cancel": "Abbrechen", + "submit": "Ära speichern", + "createdToast": "Frühere Ära gespeichert", + "createError": "Die Ära konnte nicht gespeichert werden. Sie darf sich nicht mit bestehenden Ären überschneiden und muss vor dem aktuellen Plan enden.", + "deletedToast": "Ära gelöscht", + "deleteError": "Die Ära konnte nicht gelöscht werden", + "deleteAria": "Ära löschen", + "deleteConfirmTitle": "Ära löschen?", + "deleteConfirmBody": "Verlauf und Therapietreue werden für diesen Zeitraum wieder anhand des aktuellen Plans berechnet.", + "deleteConfirmAction": "Löschen", + "loadError": "Planhistorie konnte nicht geladen werden" + }, "save": "Zeitplan speichern", "saved": "Zeitplan gespeichert", "failed": "Zeitplan konnte nicht gespeichert werden", @@ -1995,7 +2025,6 @@ "thinking": "Denke nach…", "composerPlaceholder": "Frag mich etwas zu deinen Daten…", "nudgeBubbleLabel": "Neue Nachricht vom Coach — Chat öffnen", - "selfContextChipsLabel": "Der Coach würde gern wissen:", "selfContextAdopt": { "offer": "Antwort in deine Selbstauskunft übernehmen?", "confirm": "Übernehmen", @@ -2004,8 +2033,25 @@ "duplicate": "Bereits in der Selbstauskunft enthalten", "failed": "Übernahme fehlgeschlagen. Bitte erneut versuchen." }, - "selfContextChipDismiss": "Frage verwerfen", - "selfContextChipDismissError": "Frage konnte nicht ausgeblendet werden", + "guided": { + "offerTitle": "Der Coach hat {count} Rückfragen an dich", + "offerTitleOne": "Der Coach hat eine Rückfrage an dich", + "offerBody": "Kurze Antworten schärfen deine Selbstauskunft — eine Frage nach der anderen, direkt hier im Chat.", + "offerStart": "Jetzt beantworten", + "offerLater": "Später", + "offerDismiss": "Nicht mehr fragen", + "dismissError": "Die Fragen konnten nicht verworfen werden. Bitte erneut versuchen.", + "progress": "Frage {current} von {total}", + "skip": "Überspringen", + "later": "Später", + "dismissRemaining": "Nicht mehr fragen", + "answerPlaceholder": "Deine Antwort…", + "summaryTitle": "Geschafft — danke für deine Antworten!", + "summaryAnswered": "{answered} von {total} beantwortet", + "summaryAdopted": "{adopted} in die Selbstauskunft übernommen", + "summaryAdoptedNone": "nichts Neues in die Selbstauskunft übernommen", + "summaryLink": "Selbstauskunft ansehen" + }, "voiceComingSoon": "Spracheingabe kommt mit der iOS-App in v1.5.", "threadEmpty": "Frag mich etwas zu deinen Trends, Medikamenten oder Messwerten, um zu starten.", "historyTitle": "Unterhaltungen", @@ -5109,10 +5155,21 @@ }, "coachNudge": { "title": "Proaktiver Coach", - "description": "Meldet sich höchstens einmal pro Woche, wenn deine Daten einen Blick in den Coach nahelegen — etwa bei niedriger Therapietreue oder erhöhtem Blutdruck-Wochenmittel.", + "description": "Meldet sich, wenn deine Daten einen Blick in den Coach nahelegen — etwa bei niedriger Therapietreue oder erhöhtem Blutdruck-Wochenmittel. Themen und Häufigkeit sind einstellbar.", "toggleAria": "Proaktive Coach-Hinweise umschalten", - "statusOn": "Aktiv — max. 1× pro Woche", + "statusOn": "Aktiv", "statusOff": "Aus", + "groupsLabel": "Themen", + "groupMedication": "Medikation", + "groupMedicationDesc": "Lückenhafte Einnahmen der letzten Woche.", + "groupVitals": "Vitalwerte", + "groupVitalsDesc": "Blutdruck, Erholung, Gewichtstrend, Schlaf.", + "groupRoutine": "Routine", + "groupRoutineDesc": "Abrupte Mess-Pausen und Selbstauskunft-Check-ups.", + "frequencyLabel": "Häufigkeit", + "frequencyWeekly": "Höchstens einmal pro Woche", + "frequencyBiweekly": "Höchstens alle zwei Wochen", + "frequencyAria": "Häufigkeit der Coach-Hinweise wählen", "enabledToast": "Proaktive Coach-Hinweise aktiviert.", "disabledToast": "Proaktive Coach-Hinweise deaktiviert.", "saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen." @@ -5312,6 +5369,13 @@ "scoreBody": "Dein Erholungswert ist diese Woche deutlich gesunken. Der Coach hilft dir, mögliche Ursachen einzugrenzen.", "selfContextTitle": "Hilf deinem Coach, dich besser zu kennen", "selfContextBody": "Deine Selbstauskunft ist unvollständig oder länger nicht aktualisiert. Zwei Minuten in den Einstellungen halten den Kontext des Coachs frisch.", + "weightTitle": "Dein Gewicht entfernt sich vom Zielbereich", + "weightBody": "Dein Wochenmittel hat sich weiter von deinem Zielbereich entfernt. Der Coach kann mit dir in Ruhe Bilanz ziehen.", + "sleepDebtTitle": "Mehrere kurze Nächte diese Woche", + "sleepDebtBody": "Die meisten deiner letzten Nächte lagen unter deinem Schlafziel. Der Coach hilft dir, nach Mustern zu suchen.", + "measurementGapTitle": "Dein Protokoll ist still geworden", + "measurementGapBody": "Seit einer Woche kommen keine neuen Werte an. Ein kurzer Austausch mit dem Coach hilft beim Wiedereinstieg — oder deckt eine hängende Synchronisierung auf.", + "focusSuffix": "Du wolltest auf Folgendes achten: {focus}.", "questionConditions": "Hast du chronische Erkrankungen, die der Coach kennen sollte?", "questionFocus": "Gibt es etwas Bestimmtes, worauf der Coach achten soll?", "questionAllergies": "Hast du Allergien oder Unverträglichkeiten?", diff --git a/messages/en.json b/messages/en.json index 75aa016ef..a31554d4b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1599,6 +1599,36 @@ "graceEcho": "Reminder window: {minutes} minutes", "reminderTitle": "Reminder", "manageChannels": "Manage notification channels", + "history": { + "title": "Plan history", + "currentSince": "Current plan since {date}", + "noTimes": "No fixed times", + "manualChip": "Added manually", + "showErasOne": "Show 1 earlier era", + "showErasOther": "Show {count} earlier eras", + "hideEras": "Hide earlier eras", + "addEra": "Add earlier era", + "addEraTitle": "Add earlier era", + "addEraDescription": "Record the times this medication used to be taken at — for example before you adjusted it here.", + "timesLabel": "Dose times", + "fromLabel": "From", + "untilLabel": "Until", + "untilHint": "\"Until\" is the day the following plan took over (exclusive).", + "validationTimes": "Add at least one time.", + "validationRangeMissing": "Set both the from and until dates.", + "validationRangeOrder": "The from date must lie before the until date.", + "cancel": "Cancel", + "submit": "Save era", + "createdToast": "Earlier era saved", + "createError": "Could not save the era. It must not overlap existing eras and must end before the current plan.", + "deletedToast": "Era deleted", + "deleteError": "Could not delete the era", + "deleteAria": "Delete era", + "deleteConfirmTitle": "Delete era?", + "deleteConfirmBody": "History and adherence for this period will be computed against the current plan again.", + "deleteConfirmAction": "Delete", + "loadError": "Could not load the plan history" + }, "save": "Save schedule", "saved": "Schedule saved", "failed": "Could not save the schedule", @@ -1995,7 +2025,6 @@ "thinking": "Thinking…", "composerPlaceholder": "Ask anything about your data…", "nudgeBubbleLabel": "New message from the Coach — open chat", - "selfContextChipsLabel": "The Coach would like to know:", "selfContextAdopt": { "offer": "Add this answer to your self-context?", "confirm": "Add", @@ -2004,8 +2033,25 @@ "duplicate": "Already part of your self-context", "failed": "Could not add. Please try again." }, - "selfContextChipDismiss": "Dismiss question", - "selfContextChipDismissError": "Could not dismiss the question", + "guided": { + "offerTitle": "The Coach has {count} follow-up questions for you", + "offerTitleOne": "The Coach has a follow-up question for you", + "offerBody": "Short answers sharpen your self-context — one question at a time, right here in the chat.", + "offerStart": "Answer now", + "offerLater": "Later", + "offerDismiss": "Don't ask again", + "dismissError": "Could not dismiss the questions. Please try again.", + "progress": "Question {current} of {total}", + "skip": "Skip", + "later": "Later", + "dismissRemaining": "Don't ask again", + "answerPlaceholder": "Type your answer…", + "summaryTitle": "All set — thanks for your answers!", + "summaryAnswered": "{answered} of {total} answered", + "summaryAdopted": "{adopted} added to your self-context", + "summaryAdoptedNone": "nothing new added to your self-context", + "summaryLink": "Review your self-context" + }, "voiceComingSoon": "Voice input arrives with the iOS app in v1.5.", "threadEmpty": "Ask anything about your trends, medications, or readings to start a conversation.", "historyTitle": "Conversations", @@ -5109,10 +5155,21 @@ }, "coachNudge": { "title": "Proactive Coach", - "description": "Reaches out at most once a week when your data suggests a look at the Coach — for example low medication adherence or an elevated weekly blood-pressure average.", + "description": "Reaches out when your data suggests a look at the Coach — for example low medication adherence or an elevated weekly blood-pressure average. Topics and frequency are adjustable.", "toggleAria": "Toggle proactive Coach nudges", - "statusOn": "Active — max. once a week", + "statusOn": "Active", "statusOff": "Off", + "groupsLabel": "Topics", + "groupMedication": "Medication", + "groupMedicationDesc": "Patchy intakes over the past week.", + "groupVitals": "Vitals", + "groupVitalsDesc": "Blood pressure, recovery, weight trend, sleep.", + "groupRoutine": "Routine", + "groupRoutineDesc": "Abrupt tracking stops and self-context check-ups.", + "frequencyLabel": "Frequency", + "frequencyWeekly": "At most once a week", + "frequencyBiweekly": "At most every two weeks", + "frequencyAria": "Select nudge frequency", "enabledToast": "Proactive Coach nudges enabled.", "disabledToast": "Proactive Coach nudges disabled.", "saveError": "Could not save. Please try again later." @@ -5312,6 +5369,13 @@ "scoreBody": "Your recovery score fell noticeably this week. The Coach can help you narrow down possible causes.", "selfContextTitle": "Help your Coach know you better", "selfContextBody": "Your self-description is incomplete or has not been updated in a while. Two minutes in the settings keep the Coach's context fresh.", + "weightTitle": "Your weight is drifting from your target", + "weightBody": "Your weekly average moved further away from your target range. The Coach can help you take stock calmly.", + "sleepDebtTitle": "Several short nights this week", + "sleepDebtBody": "Most of your recent nights came in under your sleep target. The Coach can help you look for patterns.", + "measurementGapTitle": "Your log went quiet", + "measurementGapBody": "No new readings arrived for a week. A quick word with the Coach helps you pick the routine back up — or spot a broken sync.", + "focusSuffix": "You wanted to keep an eye on: {focus}.", "questionConditions": "Do you have any chronic conditions the Coach should know about?", "questionFocus": "Is there anything specific the Coach should pay attention to?", "questionAllergies": "Do you have any allergies or intolerances?", diff --git a/messages/es.json b/messages/es.json index 4fe64b082..64d69e29f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1599,6 +1599,36 @@ "graceEcho": "Ventana de recordatorio: {minutes} minutos", "reminderTitle": "Recordatorio", "manageChannels": "Gestionar canales de notificación", + "history": { + "title": "Historial del plan", + "currentSince": "Plan actual desde {date}", + "noTimes": "Sin horas fijas", + "manualChip": "Añadida manualmente", + "showErasOne": "Mostrar 1 etapa anterior", + "showErasOther": "Mostrar {count} etapas anteriores", + "hideEras": "Ocultar etapas anteriores", + "addEra": "Añadir etapa anterior", + "addEraTitle": "Añadir etapa anterior", + "addEraDescription": "Registra a qué horas se tomaba antes este medicamento, por ejemplo antes de ajustarlo aquí.", + "timesLabel": "Horas de toma", + "fromLabel": "Desde", + "untilLabel": "Hasta", + "untilHint": "«Hasta» es el día en que empezó a regir el plan siguiente (exclusivo).", + "validationTimes": "Indica al menos una hora.", + "validationRangeMissing": "Indica las fechas de inicio y fin.", + "validationRangeOrder": "La fecha de inicio debe ser anterior a la fecha de fin.", + "cancel": "Cancelar", + "submit": "Guardar etapa", + "createdToast": "Etapa anterior guardada", + "createError": "No se pudo guardar la etapa. No debe solaparse con etapas existentes y debe terminar antes del plan actual.", + "deletedToast": "Etapa eliminada", + "deleteError": "No se pudo eliminar la etapa", + "deleteAria": "Eliminar etapa", + "deleteConfirmTitle": "¿Eliminar la etapa?", + "deleteConfirmBody": "El historial y la adherencia de este período volverán a calcularse con el plan actual.", + "deleteConfirmAction": "Eliminar", + "loadError": "No se pudo cargar el historial del plan" + }, "save": "Guardar horario", "saved": "Horario guardado", "failed": "No se pudo guardar el horario", @@ -1995,7 +2025,6 @@ "thinking": "Pensando…", "composerPlaceholder": "Pregúntame sobre tus datos…", "nudgeBubbleLabel": "Nuevo mensaje del coach — abrir el chat", - "selfContextChipsLabel": "Al Coach le gustaría saber:", "selfContextAdopt": { "offer": "¿Añadir esta respuesta a tu información personal?", "confirm": "Añadir", @@ -2004,8 +2033,25 @@ "duplicate": "Ya está en tu información personal", "failed": "No se pudo añadir. Inténtalo de nuevo." }, - "selfContextChipDismiss": "Descartar pregunta", - "selfContextChipDismissError": "No se pudo descartar la pregunta", + "guided": { + "offerTitle": "El Coach tiene {count} preguntas para ti", + "offerTitleOne": "El Coach tiene una pregunta para ti", + "offerBody": "Respuestas breves afinan tu información personal — una pregunta a la vez, aquí en el chat.", + "offerStart": "Responder ahora", + "offerLater": "Más tarde", + "offerDismiss": "No volver a preguntar", + "dismissError": "No se pudieron descartar las preguntas. Inténtalo de nuevo.", + "progress": "Pregunta {current} de {total}", + "skip": "Omitir", + "later": "Más tarde", + "dismissRemaining": "No volver a preguntar", + "answerPlaceholder": "Tu respuesta…", + "summaryTitle": "¡Listo — gracias por tus respuestas!", + "summaryAnswered": "{answered} de {total} respondidas", + "summaryAdopted": "{adopted} añadidas a tu información personal", + "summaryAdoptedNone": "no se añadió nada nuevo a tu información personal", + "summaryLink": "Ver tu información personal" + }, "voiceComingSoon": "La entrada por voz llega con la app de iOS en la v1.5.", "threadEmpty": "Pregúntame algo sobre tus tendencias, medicamentos o mediciones para empezar.", "historyTitle": "Conversaciones", @@ -5109,9 +5155,20 @@ }, "coachNudge": { "title": "Coach proactivo", - "description": "Se pone en contacto como máximo una vez por semana cuando tus datos sugieren consultar al Coach, por ejemplo baja adherencia a la medicación o una media semanal de tensión elevada.", + "description": "Se pone en contacto cuando tus datos sugieren consultar al Coach, por ejemplo baja adherencia a la medicación o una media semanal de tensión elevada. Los temas y la frecuencia son ajustables.", "toggleAria": "Activar o desactivar los avisos proactivos del Coach", - "statusOn": "Activo — máx. 1 vez por semana", + "statusOn": "Activo", + "groupsLabel": "Temas", + "groupMedication": "Medicación", + "groupMedicationDesc": "Tomas irregulares durante la última semana.", + "groupVitals": "Constantes vitales", + "groupVitalsDesc": "Tensión, recuperación, tendencia del peso, sueño.", + "groupRoutine": "Rutina", + "groupRoutineDesc": "Pausas bruscas en los registros y revisiones de la autodescripción.", + "frequencyLabel": "Frecuencia", + "frequencyWeekly": "Como máximo una vez por semana", + "frequencyBiweekly": "Como máximo cada dos semanas", + "frequencyAria": "Seleccionar la frecuencia de los avisos", "statusOff": "Desactivado", "enabledToast": "Avisos proactivos del Coach activados.", "disabledToast": "Avisos proactivos del Coach desactivados.", @@ -5312,6 +5369,13 @@ "scoreBody": "Tu puntuación de recuperación ha caído notablemente esta semana. El Coach puede ayudarte a acotar las posibles causas.", "selfContextTitle": "Ayuda a tu Coach a conocerte mejor", "selfContextBody": "Tu autodescripción está incompleta o lleva tiempo sin actualizarse. Dos minutos en los ajustes mantienen fresco el contexto del Coach.", + "weightTitle": "Tu peso se aleja de tu rango objetivo", + "weightBody": "Tu media semanal se ha alejado más de tu rango objetivo. El Coach puede ayudarte a hacer balance con calma.", + "sleepDebtTitle": "Varias noches cortas esta semana", + "sleepDebtBody": "La mayoría de tus últimas noches quedaron por debajo de tu objetivo de sueño. El Coach puede ayudarte a buscar patrones.", + "measurementGapTitle": "Tu registro se ha quedado en silencio", + "measurementGapBody": "Hace una semana que no llegan valores nuevos. Una breve charla con el Coach ayuda a retomar la rutina — o a detectar una sincronización rota.", + "focusSuffix": "Querías prestar atención a: {focus}.", "questionConditions": "¿Tienes enfermedades crónicas que el Coach deba conocer?", "questionFocus": "¿Hay algo concreto en lo que el Coach deba fijarse?", "questionAllergies": "¿Tienes alergias o intolerancias?", diff --git a/messages/fr.json b/messages/fr.json index bf128ed8b..3a65daf4c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1599,6 +1599,36 @@ "graceEcho": "Fenêtre de rappel : {minutes} minutes", "reminderTitle": "Rappel", "manageChannels": "Gérer les canaux de notification", + "history": { + "title": "Historique du plan", + "currentSince": "Plan actuel depuis {date}", + "noTimes": "Aucune heure fixe", + "manualChip": "Ajoutée manuellement", + "showErasOne": "Afficher 1 période antérieure", + "showErasOther": "Afficher {count} périodes antérieures", + "hideEras": "Masquer les périodes antérieures", + "addEra": "Ajouter une période antérieure", + "addEraTitle": "Ajouter une période antérieure", + "addEraDescription": "Notez à quelles heures ce médicament était pris auparavant — par exemple avant de l'ajuster ici.", + "timesLabel": "Heures de prise", + "fromLabel": "Du", + "untilLabel": "Jusqu'au", + "untilHint": "« Jusqu'au » est le jour où le plan suivant a pris effet (exclusif).", + "validationTimes": "Indiquez au moins une heure.", + "validationRangeMissing": "Indiquez les dates de début et de fin.", + "validationRangeOrder": "La date de début doit précéder la date de fin.", + "cancel": "Annuler", + "submit": "Enregistrer la période", + "createdToast": "Période antérieure enregistrée", + "createError": "Impossible d'enregistrer la période. Elle ne doit pas chevaucher des périodes existantes et doit se terminer avant le plan actuel.", + "deletedToast": "Période supprimée", + "deleteError": "Impossible de supprimer la période", + "deleteAria": "Supprimer la période", + "deleteConfirmTitle": "Supprimer la période ?", + "deleteConfirmBody": "L'historique et l'observance de cette période seront recalculés selon le plan actuel.", + "deleteConfirmAction": "Supprimer", + "loadError": "Impossible de charger l'historique du plan" + }, "save": "Enregistrer l'horaire", "saved": "Horaire enregistré", "failed": "Impossible d'enregistrer l'horaire", @@ -1995,7 +2025,6 @@ "thinking": "Réflexion en cours…", "composerPlaceholder": "Pose-moi une question sur tes données…", "nudgeBubbleLabel": "Nouveau message du coach — ouvrir le chat", - "selfContextChipsLabel": "Le Coach aimerait savoir :", "selfContextAdopt": { "offer": "Ajouter cette réponse à vos informations personnelles ?", "confirm": "Ajouter", @@ -2004,8 +2033,25 @@ "duplicate": "Déjà présent dans vos informations personnelles", "failed": "Échec de l’ajout. Veuillez réessayer." }, - "selfContextChipDismiss": "Ignorer la question", - "selfContextChipDismissError": "Impossible de masquer la question", + "guided": { + "offerTitle": "Le Coach a {count} questions pour vous", + "offerTitleOne": "Le Coach a une question pour vous", + "offerBody": "De courtes réponses affinent vos informations personnelles — une question à la fois, ici dans le chat.", + "offerStart": "Répondre maintenant", + "offerLater": "Plus tard", + "offerDismiss": "Ne plus demander", + "dismissError": "Impossible d’écarter les questions. Veuillez réessayer.", + "progress": "Question {current} sur {total}", + "skip": "Passer", + "later": "Plus tard", + "dismissRemaining": "Ne plus demander", + "answerPlaceholder": "Votre réponse…", + "summaryTitle": "C’est fait — merci pour vos réponses !", + "summaryAnswered": "{answered} sur {total} répondues", + "summaryAdopted": "{adopted} ajoutées à vos informations personnelles", + "summaryAdoptedNone": "rien de nouveau ajouté à vos informations personnelles", + "summaryLink": "Voir vos informations personnelles" + }, "voiceComingSoon": "La saisie vocale arrive avec l’app iOS en v1.5.", "threadEmpty": "Pose une question sur tes tendances, médicaments ou mesures pour démarrer.", "historyTitle": "Conversations", @@ -5109,9 +5155,20 @@ }, "coachNudge": { "title": "Coach proactif", - "description": "Vous contacte au plus une fois par semaine lorsque vos données suggèrent de consulter le Coach — par exemple une faible observance ou une moyenne hebdomadaire de tension élevée.", + "description": "Vous contacte lorsque vos données suggèrent de consulter le Coach — par exemple une faible observance ou une moyenne hebdomadaire de tension élevée. Les thèmes et la fréquence sont réglables.", "toggleAria": "Activer ou désactiver les rappels proactifs du Coach", - "statusOn": "Actif — max. 1 fois par semaine", + "statusOn": "Actif", + "groupsLabel": "Thèmes", + "groupMedication": "Médication", + "groupMedicationDesc": "Prises irrégulières au cours de la dernière semaine.", + "groupVitals": "Constantes vitales", + "groupVitalsDesc": "Tension, récupération, tendance du poids, sommeil.", + "groupRoutine": "Routine", + "groupRoutineDesc": "Arrêts brusques des mesures et bilans de l'auto-description.", + "frequencyLabel": "Fréquence", + "frequencyWeekly": "Au plus une fois par semaine", + "frequencyBiweekly": "Au plus toutes les deux semaines", + "frequencyAria": "Choisir la fréquence des rappels", "statusOff": "Désactivé", "enabledToast": "Rappels proactifs du Coach activés.", "disabledToast": "Rappels proactifs du Coach désactivés.", @@ -5312,6 +5369,13 @@ "scoreBody": "Votre score de récupération a nettement chuté cette semaine. Le Coach peut vous aider à cerner les causes possibles.", "selfContextTitle": "Aidez votre Coach à mieux vous connaître", "selfContextBody": "Votre auto-description est incomplète ou n'a pas été mise à jour depuis un moment. Deux minutes dans les réglages gardent le contexte du Coach à jour.", + "weightTitle": "Votre poids s'éloigne de votre zone cible", + "weightBody": "Votre moyenne hebdomadaire s'est encore éloignée de votre zone cible. Le Coach peut vous aider à faire le point sereinement.", + "sleepDebtTitle": "Plusieurs nuits courtes cette semaine", + "sleepDebtBody": "La plupart de vos dernières nuits sont restées sous votre objectif de sommeil. Le Coach peut vous aider à chercher des régularités.", + "measurementGapTitle": "Votre journal est devenu silencieux", + "measurementGapBody": "Aucune nouvelle valeur depuis une semaine. Un bref échange avec le Coach aide à reprendre la routine — ou à repérer une synchronisation en panne.", + "focusSuffix": "Vous vouliez surveiller : {focus}.", "questionConditions": "Avez-vous des maladies chroniques que le Coach devrait connaître ?", "questionFocus": "Y a-t-il quelque chose de précis auquel le Coach devrait veiller ?", "questionAllergies": "Avez-vous des allergies ou des intolérances ?", diff --git a/messages/it.json b/messages/it.json index b47026e16..b69b182de 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1599,6 +1599,36 @@ "graceEcho": "Finestra di promemoria: {minutes} minuti", "reminderTitle": "Promemoria", "manageChannels": "Gestisci i canali di notifica", + "history": { + "title": "Cronologia del piano", + "currentSince": "Piano attuale dal {date}", + "noTimes": "Nessun orario fisso", + "manualChip": "Aggiunta manualmente", + "showErasOne": "Mostra 1 periodo precedente", + "showErasOther": "Mostra {count} periodi precedenti", + "hideEras": "Nascondi i periodi precedenti", + "addEra": "Aggiungi periodo precedente", + "addEraTitle": "Aggiungi periodo precedente", + "addEraDescription": "Registra a che ora veniva assunto questo farmaco in passato, ad esempio prima di modificarlo qui.", + "timesLabel": "Orari di assunzione", + "fromLabel": "Dal", + "untilLabel": "Fino al", + "untilHint": "«Fino al» è il giorno in cui è entrato in vigore il piano successivo (escluso).", + "validationTimes": "Indica almeno un orario.", + "validationRangeMissing": "Indica le date di inizio e fine.", + "validationRangeOrder": "La data di inizio deve precedere la data di fine.", + "cancel": "Annulla", + "submit": "Salva periodo", + "createdToast": "Periodo precedente salvato", + "createError": "Impossibile salvare il periodo. Non deve sovrapporsi a periodi esistenti e deve terminare prima del piano attuale.", + "deletedToast": "Periodo eliminato", + "deleteError": "Impossibile eliminare il periodo", + "deleteAria": "Elimina periodo", + "deleteConfirmTitle": "Eliminare il periodo?", + "deleteConfirmBody": "La cronologia e l'aderenza di questo intervallo verranno ricalcolate in base al piano attuale.", + "deleteConfirmAction": "Elimina", + "loadError": "Impossibile caricare la cronologia del piano" + }, "save": "Salva programma", "saved": "Programma salvato", "failed": "Impossibile salvare il programma", @@ -1995,7 +2025,6 @@ "thinking": "Sto pensando…", "composerPlaceholder": "Chiedimi qualcosa sui tuoi dati…", "nudgeBubbleLabel": "Nuovo messaggio dal coach — apri la chat", - "selfContextChipsLabel": "Il Coach vorrebbe sapere:", "selfContextAdopt": { "offer": "Aggiungere questa risposta alle tue informazioni personali?", "confirm": "Aggiungi", @@ -2004,8 +2033,25 @@ "duplicate": "Già presente nelle tue informazioni personali", "failed": "Aggiunta non riuscita. Riprova." }, - "selfContextChipDismiss": "Ignora la domanda", - "selfContextChipDismissError": "Impossibile ignorare la domanda", + "guided": { + "offerTitle": "Il Coach ha {count} domande per te", + "offerTitleOne": "Il Coach ha una domanda per te", + "offerBody": "Risposte brevi affinano le tue informazioni personali — una domanda alla volta, qui in chat.", + "offerStart": "Rispondi ora", + "offerLater": "Più tardi", + "offerDismiss": "Non chiedere più", + "dismissError": "Impossibile scartare le domande. Riprova.", + "progress": "Domanda {current} di {total}", + "skip": "Salta", + "later": "Più tardi", + "dismissRemaining": "Non chiedere più", + "answerPlaceholder": "La tua risposta…", + "summaryTitle": "Fatto — grazie per le tue risposte!", + "summaryAnswered": "{answered} di {total} risposte", + "summaryAdopted": "{adopted} aggiunte alle tue informazioni personali", + "summaryAdoptedNone": "nulla di nuovo aggiunto alle tue informazioni personali", + "summaryLink": "Vedi le tue informazioni personali" + }, "voiceComingSoon": "L’input vocale arriva con l’app iOS in v1.5.", "threadEmpty": "Chiedimi qualcosa sui tuoi trend, farmaci o misurazioni per iniziare.", "historyTitle": "Conversazioni", @@ -5109,9 +5155,20 @@ }, "coachNudge": { "title": "Coach proattivo", - "description": "Si fa sentire al massimo una volta a settimana quando i tuoi dati suggeriscono di consultare il Coach — ad esempio bassa aderenza terapeutica o una media settimanale della pressione elevata.", + "description": "Si fa sentire quando i tuoi dati suggeriscono di consultare il Coach — ad esempio bassa aderenza terapeutica o una media settimanale della pressione elevata. Temi e frequenza sono regolabili.", "toggleAria": "Attiva o disattiva gli avvisi proattivi del Coach", - "statusOn": "Attivo — max 1 volta a settimana", + "statusOn": "Attivo", + "groupsLabel": "Temi", + "groupMedication": "Terapia", + "groupMedicationDesc": "Assunzioni irregolari nell'ultima settimana.", + "groupVitals": "Parametri vitali", + "groupVitalsDesc": "Pressione, recupero, tendenza del peso, sonno.", + "groupRoutine": "Routine", + "groupRoutineDesc": "Interruzioni improvvise delle misurazioni e controlli dell'autodescrizione.", + "frequencyLabel": "Frequenza", + "frequencyWeekly": "Al massimo una volta a settimana", + "frequencyBiweekly": "Al massimo ogni due settimane", + "frequencyAria": "Seleziona la frequenza degli avvisi", "statusOff": "Disattivato", "enabledToast": "Avvisi proattivi del Coach attivati.", "disabledToast": "Avvisi proattivi del Coach disattivati.", @@ -5312,6 +5369,13 @@ "scoreBody": "Il tuo punteggio di recupero è calato sensibilmente questa settimana. Il Coach può aiutarti a circoscrivere le possibili cause.", "selfContextTitle": "Aiuta il tuo Coach a conoscerti meglio", "selfContextBody": "La tua autodescrizione è incompleta o non viene aggiornata da tempo. Due minuti nelle impostazioni mantengono fresco il contesto del Coach.", + "weightTitle": "Il tuo peso si sta allontanando dall'obiettivo", + "weightBody": "La tua media settimanale si è allontanata ulteriormente dal tuo intervallo obiettivo. Il Coach può aiutarti a fare il punto con calma.", + "sleepDebtTitle": "Diverse notti brevi questa settimana", + "sleepDebtBody": "La maggior parte delle tue ultime notti è rimasta sotto il tuo obiettivo di sonno. Il Coach può aiutarti a cercare schemi ricorrenti.", + "measurementGapTitle": "Il tuo diario è rimasto in silenzio", + "measurementGapBody": "Da una settimana non arrivano nuovi valori. Due parole con il Coach aiutano a riprendere la routine — o a scoprire una sincronizzazione interrotta.", + "focusSuffix": "Volevi tenere d'occhio: {focus}.", "questionConditions": "Hai malattie croniche che il Coach dovrebbe conoscere?", "questionFocus": "C'è qualcosa di specifico a cui il Coach dovrebbe fare attenzione?", "questionAllergies": "Hai allergie o intolleranze?", diff --git a/messages/pl.json b/messages/pl.json index 2cca67983..3c2de5a51 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1599,6 +1599,36 @@ "graceEcho": "Okno przypomnienia: {minutes} minut", "reminderTitle": "Przypomnienie", "manageChannels": "Zarządzaj kanałami powiadomień", + "history": { + "title": "Historia planu", + "currentSince": "Aktualny plan od {date}", + "noTimes": "Brak stałych godzin", + "manualChip": "Dodano ręcznie", + "showErasOne": "Pokaż 1 wcześniejszy okres", + "showErasOther": "Pokaż wcześniejsze okresy ({count})", + "hideEras": "Ukryj wcześniejsze okresy", + "addEra": "Dodaj wcześniejszy okres", + "addEraTitle": "Dodaj wcześniejszy okres", + "addEraDescription": "Zapisz, o których godzinach ten lek był wcześniej przyjmowany — np. zanim zmieniono go tutaj.", + "timesLabel": "Godziny przyjmowania", + "fromLabel": "Od", + "untilLabel": "Do", + "untilHint": "„Do\" to dzień, od którego obowiązywał kolejny plan (wyłącznie).", + "validationTimes": "Podaj co najmniej jedną godzinę.", + "validationRangeMissing": "Podaj daty od i do.", + "validationRangeOrder": "Data od musi być wcześniejsza niż data do.", + "cancel": "Anuluj", + "submit": "Zapisz okres", + "createdToast": "Wcześniejszy okres zapisany", + "createError": "Nie udało się zapisać okresu. Nie może nakładać się na istniejące okresy i musi kończyć się przed aktualnym planem.", + "deletedToast": "Okres usunięty", + "deleteError": "Nie udało się usunąć okresu", + "deleteAria": "Usuń okres", + "deleteConfirmTitle": "Usunąć okres?", + "deleteConfirmBody": "Historia i przestrzeganie zaleceń w tym zakresie zostaną ponownie obliczone według aktualnego planu.", + "deleteConfirmAction": "Usuń", + "loadError": "Nie udało się wczytać historii planu" + }, "save": "Zapisz harmonogram", "saved": "Zapisano harmonogram", "failed": "Nie udało się zapisać harmonogramu", @@ -1995,7 +2025,6 @@ "thinking": "Myślę…", "composerPlaceholder": "Zapytaj o swoje dane…", "nudgeBubbleLabel": "Nowa wiadomość od trenera — otwórz czat", - "selfContextChipsLabel": "Coach chciałby wiedzieć:", "selfContextAdopt": { "offer": "Dodać tę odpowiedź do Twoich informacji o sobie?", "confirm": "Dodaj", @@ -2004,8 +2033,25 @@ "duplicate": "Już znajduje się w informacjach o sobie", "failed": "Nie udało się dodać. Spróbuj ponownie." }, - "selfContextChipDismiss": "Odrzuć pytanie", - "selfContextChipDismissError": "Nie udało się odrzucić pytania", + "guided": { + "offerTitle": "Coach ma {count} pytania do Ciebie", + "offerTitleOne": "Coach ma pytanie do Ciebie", + "offerBody": "Krótkie odpowiedzi doprecyzują Twoje informacje o sobie — jedno pytanie po drugim, tutaj na czacie.", + "offerStart": "Odpowiedz teraz", + "offerLater": "Później", + "offerDismiss": "Nie pytaj więcej", + "dismissError": "Nie udało się odrzucić pytań. Spróbuj ponownie.", + "progress": "Pytanie {current} z {total}", + "skip": "Pomiń", + "later": "Później", + "dismissRemaining": "Nie pytaj więcej", + "answerPlaceholder": "Twoja odpowiedź…", + "summaryTitle": "Gotowe — dzięki za odpowiedzi!", + "summaryAnswered": "odpowiedziano na {answered} z {total}", + "summaryAdopted": "{adopted} dodano do informacji o sobie", + "summaryAdoptedNone": "nie dodano nic nowego do informacji o sobie", + "summaryLink": "Zobacz informacje o sobie" + }, "voiceComingSoon": "Wejście głosowe pojawi się z aplikacją iOS w v1.5.", "threadEmpty": "Zapytaj o swoje trendy, leki lub pomiary, aby zacząć.", "historyTitle": "Rozmowy", @@ -5109,9 +5155,20 @@ }, "coachNudge": { "title": "Proaktywny Coach", - "description": "Odzywa się najwyżej raz w tygodniu, gdy Twoje dane sugerują zajrzenie do Coacha — np. niska systematyczność przyjmowania leków lub podwyższona tygodniowa średnia ciśnienia.", + "description": "Odzywa się, gdy Twoje dane sugerują zajrzenie do Coacha — np. niska systematyczność przyjmowania leków lub podwyższona tygodniowa średnia ciśnienia. Tematy i częstotliwość można dostosować.", "toggleAria": "Przełącz proaktywne podpowiedzi Coacha", - "statusOn": "Aktywne — maks. 1 raz w tygodniu", + "statusOn": "Aktywne", + "groupsLabel": "Tematy", + "groupMedication": "Leki", + "groupMedicationDesc": "Nieregularne przyjmowanie leków w ostatnim tygodniu.", + "groupVitals": "Parametry życiowe", + "groupVitalsDesc": "Ciśnienie, regeneracja, trend wagi, sen.", + "groupRoutine": "Rutyna", + "groupRoutineDesc": "Nagłe przerwy w pomiarach i przegląd opisu o sobie.", + "frequencyLabel": "Częstotliwość", + "frequencyWeekly": "Najwyżej raz w tygodniu", + "frequencyBiweekly": "Najwyżej co dwa tygodnie", + "frequencyAria": "Wybierz częstotliwość podpowiedzi", "statusOff": "Wyłączone", "enabledToast": "Proaktywne podpowiedzi Coacha włączone.", "disabledToast": "Proaktywne podpowiedzi Coacha wyłączone.", @@ -5312,6 +5369,13 @@ "scoreBody": "Twój wskaźnik regeneracji wyraźnie spadł w tym tygodniu. Coach pomoże Ci zawęzić możliwe przyczyny.", "selfContextTitle": "Pomóż Coachowi lepiej Cię poznać", "selfContextBody": "Twój opis o sobie jest niepełny lub dawno nie był aktualizowany. Dwie minuty w ustawieniach utrzymają kontekst Coacha w aktualnym stanie.", + "weightTitle": "Twoja waga oddala się od zakresu docelowego", + "weightBody": "Twoja tygodniowa średnia jeszcze bardziej oddaliła się od zakresu docelowego. Coach pomoże Ci spokojnie podsumować sytuację.", + "sleepDebtTitle": "Kilka krótkich nocy w tym tygodniu", + "sleepDebtBody": "Większość Twoich ostatnich nocy była poniżej celu snu. Coach pomoże Ci poszukać wzorców.", + "measurementGapTitle": "Twój dziennik ucichł", + "measurementGapBody": "Od tygodnia nie pojawiają się nowe wartości. Krótka rozmowa z Coachem pomoże wrócić do rutyny — albo wykryć zepsutą synchronizację.", + "focusSuffix": "Chciałeś/chciałaś zwracać uwagę na: {focus}.", "questionConditions": "Czy masz choroby przewlekłe, o których Coach powinien wiedzieć?", "questionFocus": "Czy jest coś konkretnego, na co Coach powinien zwracać uwagę?", "questionAllergies": "Czy masz alergie lub nietolerancje?", diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts index 8632c2e7d..3137ecb03 100644 --- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -91,7 +91,7 @@ describe("GET /api/auth/me/notification-prefs", () => { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }); }); @@ -112,7 +112,7 @@ describe("GET /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }); }); @@ -135,7 +135,7 @@ describe("GET /api/auth/me/notification-prefs", () => { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }); }); }); @@ -169,7 +169,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }); expect(prisma.user.update).toHaveBeenCalledWith({ @@ -179,7 +179,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }, }, }); @@ -193,13 +193,13 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }, next: { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }, changed: ["medication"], }), @@ -261,7 +261,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }, }, }); @@ -287,7 +287,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }); expect(prisma.user.update).toHaveBeenCalledWith({ @@ -297,7 +297,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 22 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }, }, }); @@ -326,7 +326,7 @@ describe("PATCH /api/auth/me/notification-prefs", () => { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: 9 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { nudgesEnabled: true, nudgeMedication: true, nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly" }, }, }, }); diff --git a/src/components/insights/coach-panel/__tests__/guided-dialog.test.tsx b/src/components/insights/coach-panel/__tests__/guided-dialog.test.tsx new file mode 100644 index 000000000..e79a5c3c1 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/guided-dialog.test.tsx @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +import { + GuidedQuestionBubble, + GuidedSummaryBubble, +} from "../guided-dialog-bubbles"; +import { GuidedQuestionsCard } from "../guided-questions-card"; + +/** + * v1.16.5 — markup-level coverage for the guided clarifying-questions + * surfaces (entry card, question bubble, summary bubble). Behavioural + * transitions live in `guided-questions-machine.test.ts`; placement in + * the thread lives with the `placeInterleaved` tests. + */ + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +const noop = () => {}; + +describe("", () => { + it("renders the plural offer with the count and all three choices", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="coach-guided-offer"'); + expect(html).toContain("The Coach has 3 follow-up questions for you"); + expect(html).toContain('data-slot="coach-guided-offer-start"'); + expect(html).toContain('data-slot="coach-guided-offer-later"'); + expect(html).toContain('data-slot="coach-guided-offer-dismiss"'); + expect(html).toContain("Answer now"); + }); + + it("uses the singular title for a single pending question", () => { + const html = render( + , + ); + expect(html).toContain("The Coach has a follow-up question for you"); + expect(html).not.toContain("{count}"); + }); + + it("disables every action while a request is in flight", () => { + const html = render( + , + ); + expect(html.match(/disabled=""/g)?.length).toBe(3); + }); +}); + +describe("", () => { + it("renders progress + question text for the current question with actions", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="coach-guided-question"'); + expect(html).toContain('data-state="current"'); + expect(html).toContain("Question 1 of 3"); + expect(html).toContain("Do you have any chronic conditions?"); + expect(html).toContain('data-slot="coach-guided-skip"'); + expect(html).toContain('data-slot="coach-guided-later"'); + expect(html).toContain('data-slot="coach-guided-dismiss"'); + }); + + it("renders answered questions static — no actions", () => { + const html = render( + , + ); + expect(html).toContain('data-state="answered"'); + expect(html).not.toContain('data-slot="coach-guided-actions"'); + }); + + it("localises the progress label", () => { + const html = render( + , + "de", + ); + expect(html).toContain("Frage 2 von 3"); + }); +}); + +describe("", () => { + it("recaps answered + adopted counts and links to the self-context", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="coach-guided-summary"'); + expect(html).toContain("2 of 3 answered"); + expect(html).toContain("1 added to your self-context"); + expect(html).toContain('href="/settings/ai"'); + }); + + it("says so when nothing was adopted", () => { + const html = render( + , + ); + expect(html).toContain("nothing new added to your self-context"); + }); +}); diff --git a/src/components/insights/coach-panel/__tests__/guided-questions-machine.test.ts b/src/components/insights/coach-panel/__tests__/guided-questions-machine.test.ts new file mode 100644 index 000000000..521f129c7 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/guided-questions-machine.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from "vitest"; + +import { + countAdopted, + deriveThreadItems, + GUIDED_IDLE, + guidedReducer, + type GuidedEvent, + type GuidedState, +} from "../guided-questions-machine"; + +/** + * v1.16.5 — guided clarifying-questions state machine. + * + * Pure-reducer coverage for the in-chat guided flow: start / later / + * answer / skip / exit / dismiss transitions, adoption settling, and + * the thread-item derivation the conversation surface renders from. + */ + +const QUESTIONS = [ + "Do you have any chronic conditions?", + "What should the Coach focus on?", + "Any allergies worth noting?", +]; + +function run(events: GuidedEvent[], from: GuidedState = GUIDED_IDLE) { + return events.reduce(guidedReducer, from); +} + +describe("guidedReducer — entry", () => { + it("START snapshots the questions and asks the first", () => { + const state = run([{ type: "START", questions: QUESTIONS }]); + expect(state).toEqual({ + phase: "asking", + questions: QUESTIONS, + index: 0, + outcomes: [], + }); + }); + + it("START with no questions is a no-op", () => { + expect(run([{ type: "START", questions: [] }])).toEqual(GUIDED_IDLE); + }); + + it("START mid-flow is ignored (no sequence restart)", () => { + const asking = run([{ type: "START", questions: QUESTIONS }]); + expect(guidedReducer(asking, { type: "START", questions: ["x"] })).toBe( + asking, + ); + }); + + it("LATER from idle hides the offer for the session", () => { + expect(run([{ type: "LATER" }])).toEqual({ phase: "done" }); + }); + + it("LATER mid-flow is ignored — the bubble actions own the exits", () => { + const asking = run([{ type: "START", questions: QUESTIONS }]); + expect(guidedReducer(asking, { type: "LATER" })).toBe(asking); + }); +}); + +describe("guidedReducer — answering", () => { + it("ANSWER_SUBMITTED records the outcome and waits for the turn", () => { + const state = run([ + { type: "START", questions: QUESTIONS }, + { type: "ANSWER_SUBMITTED", answer: "Hypertension." }, + ]); + expect(state.phase).toBe("answered"); + if (state.phase !== "answered") throw new Error("unreachable"); + expect(state.outcomes).toEqual([ + { + index: 0, + question: QUESTIONS[0], + answer: "Hypertension.", + adoption: "pending", + }, + ]); + }); + + it("TURN_COMPLETE advances to the next question", () => { + const state = run([ + { type: "START", questions: QUESTIONS }, + { type: "ANSWER_SUBMITTED", answer: "Hypertension." }, + { type: "TURN_COMPLETE" }, + ]); + expect(state).toMatchObject({ phase: "asking", index: 1 }); + }); + + it("TURN_COMPLETE after the last question lands on the summary", () => { + const state = run([ + { type: "START", questions: ["only one"] }, + { type: "ANSWER_SUBMITTED", answer: "An answer." }, + { type: "TURN_COMPLETE" }, + ]); + expect(state.phase).toBe("summary"); + if (state.phase !== "summary") throw new Error("unreachable"); + expect(state.outcomes).toHaveLength(1); + }); + + it("composer submits outside the asking phase change nothing", () => { + const summary = run([ + { type: "START", questions: ["only one"] }, + { type: "ANSWER_SUBMITTED", answer: "An answer." }, + { type: "TURN_COMPLETE" }, + ]); + expect( + guidedReducer(summary, { type: "ANSWER_SUBMITTED", answer: "more" }), + ).toBe(summary); + }); +}); + +describe("guidedReducer — skip / exit / reset", () => { + it("SKIP advances without an outcome (question stays pending)", () => { + const state = run([ + { type: "START", questions: QUESTIONS }, + { type: "SKIP" }, + ]); + expect(state).toMatchObject({ phase: "asking", index: 1, outcomes: [] }); + }); + + it("skipping every question ends without a summary", () => { + const state = run([ + { type: "START", questions: QUESTIONS }, + { type: "SKIP" }, + { type: "SKIP" }, + { type: "SKIP" }, + ]); + expect(state).toEqual({ phase: "done" }); + }); + + it("SKIP on the last question shows the summary when answers exist", () => { + const state = run([ + { type: "START", questions: QUESTIONS }, + { type: "ANSWER_SUBMITTED", answer: "Hypertension." }, + { type: "TURN_COMPLETE" }, + { type: "SKIP" }, + { type: "SKIP" }, + ]); + expect(state.phase).toBe("summary"); + }); + + it("EXIT with answers lands on the summary; without answers it just ends", () => { + const withAnswer = run([ + { type: "START", questions: QUESTIONS }, + { type: "ANSWER_SUBMITTED", answer: "Hypertension." }, + { type: "TURN_COMPLETE" }, + { type: "EXIT" }, + ]); + expect(withAnswer.phase).toBe("summary"); + + const withoutAnswer = run([ + { type: "START", questions: QUESTIONS }, + { type: "EXIT" }, + ]); + expect(withoutAnswer).toEqual({ phase: "done" }); + }); + + it("RESET drops any session back to idle", () => { + const state = run([ + { type: "START", questions: QUESTIONS }, + { type: "ANSWER_SUBMITTED", answer: "Hypertension." }, + { type: "RESET" }, + ]); + expect(state).toEqual(GUIDED_IDLE); + }); +}); + +describe("guidedReducer — adoption settling", () => { + const answeredOne: GuidedEvent[] = [ + { type: "START", questions: QUESTIONS }, + { type: "ANSWER_SUBMITTED", answer: "Hypertension." }, + { type: "TURN_COMPLETE" }, + ]; + + it("settles the matching pending outcome", () => { + const state = run([ + ...answeredOne, + { type: "ADOPTION_SETTLED", index: 0, adoption: "adopted" }, + ]); + if (state.phase !== "asking") throw new Error("unreachable"); + expect(state.outcomes[0].adoption).toBe("adopted"); + }); + + it("never resettles an already-settled outcome", () => { + const state = run([ + ...answeredOne, + { type: "ADOPTION_SETTLED", index: 0, adoption: "declined" }, + { type: "ADOPTION_SETTLED", index: 0, adoption: "adopted" }, + ]); + if (state.phase !== "asking") throw new Error("unreachable"); + expect(state.outcomes[0].adoption).toBe("declined"); + }); + + it("still settles outcomes while the summary is showing", () => { + const state = run([ + { type: "START", questions: ["only one"] }, + { type: "ANSWER_SUBMITTED", answer: "An answer." }, + { type: "TURN_COMPLETE" }, + { type: "ADOPTION_SETTLED", index: 0, adoption: "adopted" }, + ]); + if (state.phase !== "summary") throw new Error("unreachable"); + expect(countAdopted(state.outcomes)).toBe(1); + }); +}); + +describe("deriveThreadItems", () => { + it("idle and done contribute nothing", () => { + expect(deriveThreadItems(GUIDED_IDLE)).toEqual([]); + expect(deriveThreadItems({ phase: "done" })).toEqual([]); + }); + + it("asking renders answered questions anchored + the current at the tail", () => { + const state = run([ + { type: "START", questions: QUESTIONS }, + { type: "ANSWER_SUBMITTED", answer: "Hypertension." }, + { type: "TURN_COMPLETE" }, + ]); + const items = deriveThreadItems(state); + expect(items).toHaveLength(2); + expect(items[0]).toMatchObject({ + kind: "question", + question: QUESTIONS[0], + progress: { current: 1, total: 3 }, + anchorAnswer: "Hypertension.", + }); + expect(items[1]).toMatchObject({ + kind: "question", + question: QUESTIONS[1], + progress: { current: 2, total: 3 }, + anchorAnswer: null, + current: true, + }); + }); + + it("a skipped question keeps the original numbering on the next one", () => { + const state = run([ + { type: "START", questions: QUESTIONS }, + { type: "SKIP" }, + ]); + const items = deriveThreadItems(state); + expect(items).toHaveLength(1); + expect(items[0].progress).toEqual({ current: 2, total: 3 }); + }); + + it("answered phase has no current item (the turn is streaming)", () => { + const state = run([ + { type: "START", questions: QUESTIONS }, + { type: "ANSWER_SUBMITTED", answer: "Hypertension." }, + ]); + const items = deriveThreadItems(state); + expect(items).toHaveLength(1); + expect(items[0].anchorAnswer).toBe("Hypertension."); + }); + + it("summary carries answered / adopted / total counts", () => { + const state = run([ + { type: "START", questions: QUESTIONS }, + { type: "ANSWER_SUBMITTED", answer: "Hypertension." }, + { type: "TURN_COMPLETE" }, + { type: "ADOPTION_SETTLED", index: 0, adoption: "adopted" }, + { type: "ANSWER_SUBMITTED", answer: "Sleep." }, + { type: "TURN_COMPLETE" }, + { type: "ADOPTION_SETTLED", index: 1, adoption: "declined" }, + { type: "SKIP" }, + ]); + const items = deriveThreadItems(state); + const summary = items.at(-1); + expect(summary).toMatchObject({ + kind: "summary", + anchorAnswer: null, + summary: { answered: 2, adopted: 1, total: 3 }, + }); + }); +}); 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 fb6748885..53dd831fb 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -52,7 +52,11 @@ vi.mock("@tanstack/react-query", () => ({ }), })); -import { MessageThread } from "../message-thread"; +import { + MessageThread, + placeInterleaved, + type InterleavedThreadItem, +} from "../message-thread"; import type { CoachConversationDetailDTO } from "@/lib/ai/coach/types"; function render(node: React.ReactNode, locale: "en" | "de" = "en") { @@ -599,3 +603,126 @@ describe("errorCodeToI18nKey", () => { ); }); }); + +/** + * v1.16.5 — placement of guided-flow bubbles between the persisted + * messages, the optimistic user bubble, and the thread tail. + */ +describe("placeInterleaved", () => { + const item = (key: string, anchorAnswer: string | null): InterleavedThreadItem => ({ + key, + anchorAnswer, + node: , + }); + const user = (id: string, content: string) => ({ + id, + role: "user", + content, + }); + const assistant = (id: string, content: string) => ({ + id, + role: "assistant", + content, + }); + + it("anchors an answered question before its persisted user message", () => { + const placed = placeInterleaved( + [item("q1", "My answer"), item("q2", null)], + [ + user("m1", "Earlier turn"), + assistant("m2", "Earlier reply"), + user("m3", "My answer"), + assistant("m4", "Reply"), + ], + null, + ); + expect(placed.before.get("m3")?.key).toBe("q1"); + expect(placed.beforeOptimistic).toEqual([]); + expect(placed.tail.map((i) => i.key)).toEqual(["q2"]); + }); + + it("anchors before the optimistic bubble while the twin is in flight", () => { + const placed = placeInterleaved( + [item("q1", "My answer")], + [user("m1", "Earlier turn")], + "My answer", + ); + expect(placed.before.size).toBe(0); + expect(placed.beforeOptimistic.map((i) => i.key)).toEqual(["q1"]); + expect(placed.tail).toEqual([]); + }); + + it("matches assistant-content collisions never (user messages only)", () => { + const placed = placeInterleaved( + [item("q1", "My answer")], + [assistant("m1", "My answer")], + null, + ); + expect(placed.before.size).toBe(0); + expect(placed.tail.map((i) => i.key)).toEqual(["q1"]); + }); + + it("consumes duplicate answers in order", () => { + const placed = placeInterleaved( + [item("q1", "Yes"), item("q2", "Yes")], + [user("m1", "Yes"), assistant("m2", "r"), user("m3", "Yes")], + null, + ); + expect(placed.before.get("m1")?.key).toBe("q1"); + expect(placed.before.get("m3")?.key).toBe("q2"); + }); + + it("falls back to the tail when an anchor never matches", () => { + const placed = placeInterleaved( + [item("q1", "Lost answer"), item("summary", null)], + [user("m1", "Something else")], + null, + ); + expect(placed.before.size).toBe(0); + expect(placed.tail.map((i) => i.key)).toEqual(["q1", "summary"]); + }); +}); + +describe(" interleaved guided bubbles", () => { + it("renders an unanchored item at the tail even on an empty thread", () => { + const html = render( + First question?, + }, + ]} + />, + ); + // The guided bubble suppresses the empty-state hero… + expect(html).not.toContain( + "Ask anything about your trends, medications, or readings", + ); + // …and renders inside the scroller. + expect(html).toContain('data-slot="guided-test-bubble"'); + expect(html).toContain("First question?"); + }); + + it("renders an anchored item before the user message that answered it", () => { + const html = render( + Guided question?, + }, + ]} + />, + ); + const guidedAt = html.indexOf('data-slot="guided-test-bubble"'); + const userAt = html.indexOf('data-slot="coach-bubble-user"'); + expect(guidedAt).toBeGreaterThan(-1); + expect(userAt).toBeGreaterThan(-1); + expect(guidedAt).toBeLessThan(userAt); + }); +}); diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index b09b065d0..454bf2b10 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -1,20 +1,33 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useReducer, useState } from "react"; import Link from "next/link"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Plus, Settings, Sparkles } from "lucide-react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; 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 { CoachDrawerBody } from "./coach-drawer-body"; import { CoachInput } from "./coach-input"; +import { + GuidedQuestionBubble, + GuidedSummaryBubble, +} from "./guided-dialog-bubbles"; +import { GuidedQuestionsCard } from "./guided-questions-card"; +import { + deriveThreadItems, + GUIDED_IDLE, + guidedReducer, +} from "./guided-questions-machine"; import { HistoryRail } from "./history-rail"; -import { MessageThread } from "./message-thread"; +import { MessageThread, type InterleavedThreadItem } from "./message-thread"; import { MobileRailTray } from "./mobile-rail-tray"; import { SelfContextAdoptOffer } from "./self-context-adopt-offer"; -import { SelfContextChips } from "./self-context-chips"; import { SourcesRail } from "./sources-rail"; import { useResettableValue } from "./use-resettable-value"; import { useCoachConversation, useSendCoachMessage } from "./use-coach"; @@ -114,18 +127,46 @@ export function CoachConversation({ const [historyTrayOpen, setHistoryTrayOpen] = useState(false); const [sourcesTrayOpen, setSourcesTrayOpen] = useState(false); const [inputValue, setInputValue] = useResettableValue(prefill ?? ""); - // v1.16.4 — self-context backflow. `activeChipQuestion` remembers the - // clarifying-question chip the user tapped (the chip pre-fills the - // composer); when the next message answers it, `pendingAdopt` raises a - // quiet offer to fold the answer back into the Selbstauskunft. - const [activeChipQuestion, setActiveChipQuestion] = useState( - null, - ); + // v1.16.4 — self-context backflow: `pendingAdopt` raises a quiet + // offer to fold a clarifying-question answer back into the + // Selbstauskunft. v1.16.5 — the answers now come from the guided + // flow; `guidedIndex` routes the offer's outcome back into the + // machine so the closing summary can say what was adopted. const [pendingAdopt, setPendingAdopt] = useState<{ question: string; answer: string; + guidedIndex: number; } | null>(null); + // v1.16.5 — guided clarifying-questions flow (V2 of the v1.16.0 + // chips). The machine is pure (`guided-questions-machine.ts`); this + // component owns the server round-trips: the pending-questions query + // feeding the entry card, the per-question dismiss on answer, and + // the dismiss-all behind "don't ask again". + const [guided, dispatchGuided] = useReducer(guidedReducer, GUIDED_IDLE); + const queryClient = useQueryClient(); + const { data: questionsData } = useQuery({ + queryKey: queryKeys.coachAboutMeQuestions(), + queryFn: async () => + apiGet<{ questions: string[] }>("/api/coach/about-me/questions"), + staleTime: 60_000, + }); + const pendingQuestions = questionsData?.questions ?? []; + const dismissQuestions = useMutation({ + mutationKey: queryKeys.coachAboutMeQuestions(), + mutationFn: async (question?: string) => + apiDelete<{ questions: string[] }>( + "/api/coach/about-me/questions", + question === undefined ? {} : { question }, + ), + onSuccess: (next) => { + queryClient.setQueryData(queryKeys.coachAboutMeQuestions(), next); + }, + onError: () => { + toast.error(t("insights.coach.guided.dismissError")); + }, + }); + const { data: conversation } = useCoachConversation(currentConversationId); const send = useSendCoachMessage({ onDone: (resolvedId) => { @@ -143,44 +184,96 @@ export function CoachConversation({ send.cancel(); setCurrentConversationId(null); setInputValue(""); + setPendingAdopt(null); + dispatchGuided({ type: "RESET" }); }); }, [registerReset, send, setInputValue]); async function handleSubmit(value: string) { const trimmed = value.trim(); if (!trimmed || send.isStreaming) return; - // v1.16.4 — when this message answers a tapped clarifying-question - // chip, peel the inserted question off the front so only the user's - // own words form the adoptable answer. An empty remainder (the user - // deleted their answer or sent the bare question) raises no offer. - const chipQuestion = activeChipQuestion; - setActiveChipQuestion(null); - const answer = chipQuestion - ? trimmed.startsWith(chipQuestion) - ? trimmed.slice(chipQuestion.length).trim() - : trimmed - : null; + // v1.16.5 — in the guided flow the composer message IS the answer + // to the current question. Mark it answered before the send so the + // question bubble anchors above the user's message, and dismiss the + // question server-side — answered questions never return. + const guidedQuestion = + guided.phase === "asking" ? guided.questions[guided.index] : null; + const guidedIndex = guided.phase === "asking" ? guided.index : null; + if (guidedQuestion !== null) { + dispatchGuided({ type: "ANSWER_SUBMITTED", answer: trimmed }); + dismissQuestions.mutate(guidedQuestion); + } setInputValue(""); await send.send({ conversationId: currentConversationId ?? undefined, message: trimmed, }); - if (chipQuestion && answer) { - setPendingAdopt({ question: chipQuestion, answer }); + if (guidedQuestion !== null && guidedIndex !== null) { + setPendingAdopt({ + question: guidedQuestion, + answer: trimmed, + guidedIndex, + }); + // The assistant turn is done — advance to the next question (or + // the closing summary). + dispatchGuided({ type: "TURN_COMPLETE" }); } } function handleNewChat() { setCurrentConversationId(null); setInputValue(""); - setActiveChipQuestion(null); setPendingAdopt(null); + dispatchGuided({ type: "RESET" }); send.reset(); } const title = conversation?.title ?? t("insights.coach.newChat"); const tagline = t("insights.coach.tagline"); + // v1.16.5 — materialise the machine's thread items into bubbles. The + // thread only places them (`placeInterleaved`); every behaviour stays + // here with the flow. + const interleaved: InterleavedThreadItem[] = deriveThreadItems(guided).map( + (item) => ({ + key: item.key, + anchorAnswer: item.anchorAnswer, + node: + item.kind === "summary" ? ( + + ) : ( + dispatchGuided({ type: "SKIP" }) + : undefined + } + onLater={ + item.current + ? () => dispatchGuided({ type: "EXIT" }) + : undefined + } + onDismissRemaining={ + item.current + ? () => + dismissQuestions.mutate(undefined, { + onSuccess: () => dispatchGuided({ type: "EXIT" }), + }) + : undefined + } + /> + ), + }), + ); + return (
setCurrentConversationId(id)} + onSelect={(id) => { + // v1.16.5 — switching conversations drops the guided + // session; unanswered questions stay pending. + setCurrentConversationId(id); + setPendingAdopt(null); + dispatchGuided({ type: "RESET" }); + }} /> ) : undefined } @@ -259,30 +358,47 @@ export function CoachConversation({ conversation={conversation ?? null} streaming={send.streaming} optimisticUser={send.optimisticUser} + interleaved={interleaved} /> } composer={
- {/* v1.16.0 — pending self-context questions as tappable - chips. Tapping inserts the question into the composer - (the user answers it in their own words) and dismisses - the chip. Renders nothing when no questions pend. */} - {/* v1.16.4 — quiet adopt-into-self-context offer once a chip - question has been answered. Self-removes after settle. */} + {/* v1.16.4 — quiet adopt-into-self-context offer once a + clarifying question has been answered. Self-removes + after settle; v1.16.5 reports the outcome back into the + guided machine for the closing summary. */} {pendingAdopt && !send.isStreaming ? ( setPendingAdopt(null)} + onSettled={(adoption) => + dispatchGuided({ + type: "ADOPTION_SETTLED", + index: pendingAdopt.guidedIndex, + adoption, + }) + } + /> + ) : null} + {/* v1.16.5 — guided clarifying-questions entry card (V2 of + the v1.16.0 chips). Offers the in-chat sequence while + questions pend and the flow hasn't started. */} + {guided.phase === "idle" && pendingQuestions.length > 0 ? ( + + dispatchGuided({ type: "START", questions: pendingQuestions }) + } + onLater={() => dispatchGuided({ type: "LATER" })} + onDismissAll={() => + dismissQuestions.mutate(undefined, { + onSuccess: () => dispatchGuided({ type: "LATER" }), + }) + } /> ) : null} - { - setActiveChipQuestion(question); - setInputValue(`${question}\n`); - }} - />
} @@ -311,6 +432,8 @@ export function CoachConversation({ onSelect={(id) => { setCurrentConversationId(id); setHistoryTrayOpen(false); + setPendingAdopt(null); + dispatchGuided({ type: "RESET" }); }} /> } diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index be5fa2780..6d75fba81 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -59,6 +59,12 @@ export interface CoachInputProps { * already-open drawer we do not steal focus back. */ autoFocusOnOpen?: boolean; + /** + * v1.16.5 — placeholder override. The guided clarifying-questions + * flow swaps the generic "ask anything" prompt for an answer cue + * while a question is live. Falls back to the stock composer copy. + */ + placeholder?: string; } /** @@ -105,6 +111,7 @@ export function CoachInput({ isStreaming = false, inputId = "coach-composer-textarea", autoFocusOnOpen = false, + placeholder, }: CoachInputProps) { const { t } = useTranslations(); const textareaRef = useRef(null); @@ -202,7 +209,7 @@ export function CoachInput({ value={value} onChange={(e) => onChange(e.target.value)} onKeyDown={handleKeyDown} - placeholder={t("insights.coach.composerPlaceholder")} + placeholder={placeholder ?? t("insights.coach.composerPlaceholder")} disabled={disabled} // v1.4.27 MB3 / CF-30 — surface the "send" virtual-keyboard // return on iOS / Android instead of the generic newline diff --git a/src/components/insights/coach-panel/guided-dialog-bubbles.tsx b/src/components/insights/coach-panel/guided-dialog-bubbles.tsx new file mode 100644 index 000000000..c9b6deb68 --- /dev/null +++ b/src/components/insights/coach-panel/guided-dialog-bubbles.tsx @@ -0,0 +1,241 @@ +"use client"; + +/** + * v1.16.5 — in-thread bubbles for the guided clarifying-questions flow + * (see `guided-questions-machine.ts` for the state machine). + * + * Two bubbles, both deterministic (no model call) and both styled as a + * close sibling of the assistant bubble so the sequence reads as the + * Coach speaking — same avatar position and bubble geometry, but a + * purple-tinted surface and a question-mark avatar so a guided turn is + * visually distinct from AI prose: + * + * - `GuidedQuestionBubble` — one clarifying question with a + * "question 1 of 3" progress line + dot strip. The CURRENT question + * briefly shows the shared typing-dots indicator before the text + * reveals (the classic scripted-bot rhythm) and carries the quiet + * skip / later / don't-ask-again actions beneath the bubble. + * Answered questions re-render static (no actions, no reveal), + * anchored above the answer that resolved them. + * - `GuidedSummaryBubble` — the closing recap: how many questions + * were answered, how many answers were adopted into the + * self-context, and a deep-link to Settings → AI to review it. + */ +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { + ArrowRight, + ClipboardCheck, + MessageCircleQuestion, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; + +import { TypingDots } from "./message-thread"; + +/** Shared quiet text-button styling for the per-question actions. */ +const guidedActionClass = cn( + "text-muted-foreground hover:text-foreground inline-flex min-h-8 items-center rounded-full px-2.5 text-xs", + "hover:bg-dracula-purple/10 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none", + "disabled:opacity-60", +); + +function GuidedAvatar({ icon: Icon }: { icon: typeof ClipboardCheck }) { + return ( + + ); +} + +/** + * Dot strip mirroring the textual progress label: filled = asked + * (everything before the current question, answered or skipped), + * outlined = the current one, muted = still ahead. Decorative — the + * "question 1 of 3" text is the accessible progress signal. + */ +function ProgressDots({ current, total }: { current: number; total: number }) { + return ( +
); } @@ -593,8 +698,12 @@ function ChatBubble({ * with staggered delays so no custom keyframe is introduced; * `motion-reduce` freezes the dots and the `label` stays as the * screen-reader text either way. + * + * Exported since v1.16.5: the guided clarifying-question bubble replays + * the same indicator before a deterministic question reveals, so the + * scripted turns share one rhythm with the streamed ones. */ -function TypingDots({ label }: { label: string }) { +export function TypingDots({ label }: { label: string }) { return ( void; + /** + * v1.16.5 — reports the offer's outcome once, so the guided + * clarifying-questions flow can count adoptions for its closing + * summary. Fired on server settle (adopted / duplicate), on a failed + * request, and on an explicit decline — never on the auto-fold + * timeout (the outcome was already reported by then). + */ + onSettled?: ( + outcome: "adopted" | "duplicate" | "declined" | "failed", + ) => void; } export function SelfContextAdoptOffer({ question, answer, onDismiss, + onSettled, }: SelfContextAdoptOfferProps) { const { t } = useTranslations(); const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null); @@ -44,6 +55,10 @@ export function SelfContextAdoptOffer({ }, onSuccess: (data) => { setSettled(data.adopted ? "adopted" : "duplicate"); + onSettled?.(data.adopted ? "adopted" : "duplicate"); + }, + onError: () => { + onSettled?.("failed"); }, }); @@ -108,7 +123,10 @@ export function SelfContextAdoptOffer({ type="button" aria-label={t("insights.coach.selfContextAdopt.dismiss")} data-slot="coach-self-context-adopt-dismiss" - onClick={onDismiss} + onClick={() => { + onSettled?.("declined"); + onDismiss(); + }} className={cn( "text-muted-foreground hover:text-foreground flex min-h-8 items-center rounded-full px-1.5", "hover:bg-dracula-purple/15 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none", diff --git a/src/components/insights/coach-panel/self-context-chips.tsx b/src/components/insights/coach-panel/self-context-chips.tsx deleted file mode 100644 index 6b77ac2c6..000000000 --- a/src/components/insights/coach-panel/self-context-chips.tsx +++ /dev/null @@ -1,123 +0,0 @@ -"use client"; - -/** - * v1.16.0 — pending self-context questions as composer chips. - * - * After the user saves Settings → AI → "About me", the server derives - * up to 3 clarifying questions (`/api/coach/about-me/questions`). They - * surface here, directly above the Coach composer, as quiet tappable - * chips: tapping one inserts the question into the composer (so the - * user answers it in their own words) and dismisses it; the ✕ - * dismisses without inserting. No questions → nothing renders, zero - * vertical cost. - * - * Each chip is a `role="group"` of two sibling buttons (insert + - * dismiss) — buttons cannot nest, and both actions deserve their own - * focus stop. - */ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { MessageCircleQuestion, X } from "lucide-react"; -import { toast } from "sonner"; - -import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; -import { cn } from "@/lib/utils"; -import { apiDelete, apiGet } from "@/lib/api/api-fetch"; - -export interface SelfContextChipsProps { - /** Insert the tapped question into the composer. */ - onPick: (question: string) => void; - /** Hidden while a reply streams so the input value cannot be yanked. */ - disabled?: boolean; -} - -export function SelfContextChips({ - onPick, - disabled = false, -}: SelfContextChipsProps) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - - const { data } = useQuery({ - queryKey: queryKeys.coachAboutMeQuestions(), - queryFn: async () => { - return apiGet<{ questions: string[] }>("/api/coach/about-me/questions"); - }, - staleTime: 60_000, - }); - - const dismiss = useMutation({ - mutationKey: queryKeys.coachAboutMeQuestions(), - mutationFn: async (question: string) => { - return apiDelete<{ questions: string[] }>("/api/coach/about-me/questions", { question }); - }, - onSuccess: (next) => { - queryClient.setQueryData(queryKeys.coachAboutMeQuestions(), next); - }, - // v1.16.4 — a failed dismiss used to fail silently, leaving the chip - // in place with no signal that the request was rejected. - onError: () => { - toast.error(t("insights.coach.selfContextChipDismissError")); - }, - }); - - const questions = data?.questions ?? []; - if (questions.length === 0) return null; - - return ( -
-

-

-
    - {questions.map((question) => ( -
  • - - -
  • - ))} -
-
- ); -} diff --git a/src/components/settings/coach-nudge-card.tsx b/src/components/settings/coach-nudge-card.tsx index fa3edba3a..4f2177208 100644 --- a/src/components/settings/coach-nudge-card.tsx +++ b/src/components/settings/coach-nudge-card.tsx @@ -7,23 +7,67 @@ * `notificationPrefs.coach.nudgesEnabled` through the roaming * notification-prefs blob so web + iOS share one source of truth. * + * v1.16.5 — per-group toggles (medication / vitals / routine) plus the + * frequency pref (weekly / biweekly) underneath the master switch. All + * of it rides the same `coach` sub-object of the prefs blob; the cron + * resolves it via `resolveCoachNudgePrefs`. + * * Mirrors `` (optimistic switch, auto-clearing inline - * status line). + * status line, NativeSelect for the enum pref). */ import { useEffect, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { MessageCircleHeart } from "lucide-react"; import { Switch } from "@/components/ui/switch"; +import { NativeSelect } from "@/components/ui/native-select"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; -interface NotificationPrefsShape { - coach: { nudgesEnabled: boolean }; +interface CoachPrefsShape { + nudgesEnabled: boolean; + nudgeMedication: boolean; + nudgeVitals: boolean; + nudgeRoutine: boolean; + nudgeFrequency: "weekly" | "biweekly"; } +interface NotificationPrefsShape { + coach: CoachPrefsShape; +} + +const COACH_PREF_DEFAULTS: CoachPrefsShape = { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", +}; + +const GROUP_FIELDS = [ + "nudgeMedication", + "nudgeVitals", + "nudgeRoutine", +] as const; +type GroupField = (typeof GROUP_FIELDS)[number]; + +const GROUP_I18N: Record = { + nudgeMedication: { + label: "notifications.coachNudge.groupMedication", + desc: "notifications.coachNudge.groupMedicationDesc", + }, + nudgeVitals: { + label: "notifications.coachNudge.groupVitals", + desc: "notifications.coachNudge.groupVitalsDesc", + }, + nudgeRoutine: { + label: "notifications.coachNudge.groupRoutine", + desc: "notifications.coachNudge.groupRoutineDesc", + }, +}; + export function CoachNudgeCard({ isAuthenticated, }: { @@ -31,7 +75,7 @@ export function CoachNudgeCard({ }) { const { t } = useTranslations(); const queryClient = useQueryClient(); - const [optimistic, setOptimistic] = useState(null); + const [optimistic, setOptimistic] = useState>({}); const [saving, setSaving] = useState(false); const [msg, setMsg] = useState(null); const [msgType, setMsgType] = useState<"success" | "error" | null>(null); @@ -66,10 +110,23 @@ export function CoachNudgeCard({ enabled: isAuthenticated, }); - const enabled = optimistic ?? prefs?.coach?.nudgesEnabled ?? true; + const resolved: CoachPrefsShape = { + ...COACH_PREF_DEFAULTS, + ...(prefs?.coach ?? {}), + ...optimistic, + }; + const enabled = resolved.nudgesEnabled; - async function handleToggle(next: boolean) { - setOptimistic(next); + /** + * One PATCH path for every knob on the card. `toast` carries the + * success line for the master toggle; the sub-prefs pass null and + * stay silent on success — the control itself shows the new state. + */ + async function patchCoach( + partial: Partial, + toast: string | null, + ) { + setOptimistic((prev) => ({ ...prev, ...partial })); setSaving(true); setMsg(null); setMsgType(null); @@ -81,23 +138,21 @@ export function CoachNudgeCard({ const res = await apiFetchRaw("/api/auth/me/notification-prefs", { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ coach: { nudgesEnabled: next } }), + body: JSON.stringify({ coach: partial }), }); if (res.ok) { - setMsg( - next - ? t("notifications.coachNudge.enabledToast") - : t("notifications.coachNudge.disabledToast"), - ); - setMsgType("success"); + if (toast) { + setMsg(toast); + setMsgType("success"); + scheduleClear(); + } await queryClient.invalidateQueries({ queryKey: queryKeys.authNotificationPrefs(), }); - setOptimistic(null); - scheduleClear(); + setOptimistic({}); } else { - setOptimistic(null); + setOptimistic({}); setMsg(t("notifications.coachNudge.saveError")); setMsgType("error"); scheduleClear(); @@ -105,6 +160,15 @@ export function CoachNudgeCard({ setSaving(false); } + async function handleToggle(next: boolean) { + await patchCoach( + { nudgesEnabled: next }, + next + ? t("notifications.coachNudge.enabledToast") + : t("notifications.coachNudge.disabledToast"), + ); + } + return (
} /> + {enabled && ( +
+

+ {t("notifications.coachNudge.groupsLabel")} +

+ {GROUP_FIELDS.map((field) => ( +
+
+

{t(GROUP_I18N[field].label)}

+

+ {t(GROUP_I18N[field].desc)} +

+
+ patchCoach({ [field]: next }, null)} + disabled={!isAuthenticated || saving} + aria-label={t(GROUP_I18N[field].label)} + /> +
+ ))} +
+ + + patchCoach( + { + nudgeFrequency: e.target.value as "weekly" | "biweekly", + }, + null, + ) + } + aria-label={t("notifications.coachNudge.frequencyAria")} + > + + + +
+
+ )} {msg && (

({ + getAssistantFlags: vi.fn(async () => ({ coach: true })), +})); +vi.mock("@/lib/ai/provider", () => ({ + userRowHasProviderCredential: vi.fn(() => true), +})); +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + decryptFromBytes: vi.fn(() => "morning blood pressure"), + encryptToBytes: vi.fn(), +})); function dose(taken: boolean, skipped = false, autoMissed?: boolean) { return { @@ -164,11 +185,154 @@ describe("buildCoachNudgePayload", () => { it("produces a distinct payload per trigger", () => { const titles = new Set( - (["compliance", "bp", "score", "selfContext"] as const).map( - (trigger) => buildCoachNudgePayload(trigger, "en").title, - ), + ( + [ + "compliance", + "bp", + "score", + "selfContext", + "weight", + "sleepDebt", + "measurementGap", + ] as const + ).map((trigger) => buildCoachNudgePayload(trigger, "en").title), ); - expect(titles.size).toBe(4); + expect(titles.size).toBe(7); + }); + + it("appends the personal focus suffix when a Coach focus exists", () => { + const { body } = buildCoachNudgePayload("bp", "en", "morning readings"); + expect(body).toContain("You wanted to keep an eye on: morning readings."); + }); + + it("clamps an over-long focus before quoting it", () => { + const focus = "x".repeat(COACH_NUDGE_FOCUS_MAX_CHARS + 40); + const { body } = buildCoachNudgePayload("weight", "en", focus); + expect(body).toContain("…"); + expect(body).not.toContain(focus); + }); + + it("skips the suffix for the self-context check-up nudge", () => { + const plain = buildCoachNudgePayload("selfContext", "en"); + const withFocus = buildCoachNudgePayload( + "selfContext", + "en", + "morning readings", + ); + expect(withFocus).toEqual(plain); + }); + + it("skips the suffix for a null / blank focus", () => { + const plain = buildCoachNudgePayload("bp", "en"); + expect(buildCoachNudgePayload("bp", "en", null)).toEqual(plain); + expect(buildCoachNudgePayload("bp", "en", " ")).toEqual(plain); + }); +}); + +describe("evaluateWeightTrigger", () => { + const range = { greenMin: 60, greenMax: 80 }; + + it("fires when the weekly mean sits outside the range and drifts away", () => { + expect( + evaluateWeightTrigger([82, 82.5, 83], [81, 81.3, 81.5], range), + ).toBe(true); + }); + + it("fires for a drift below the range too", () => { + expect(evaluateWeightTrigger([57, 57, 57], [59, 59, 59], range)).toBe( + true, + ); + }); + + it("stays silent inside the range", () => { + expect(evaluateWeightTrigger([79, 79, 79], [70, 70, 70], range)).toBe( + false, + ); + }); + + it("stays silent while converging back toward the range", () => { + expect(evaluateWeightTrigger([81, 81, 81], [83, 83, 83], range)).toBe( + false, + ); + }); + + it("stays silent under the drift floor", () => { + // 1.3 kg vs 1.0 kg from the range — only 0.3 kg of drift. + expect( + evaluateWeightTrigger([81.3, 81.3, 81.3], [81, 81, 81], range), + ).toBe(false); + }); + + it("requires enough readings in both windows", () => { + expect(evaluateWeightTrigger([85, 85], [81, 81, 81], range)).toBe(false); + expect(evaluateWeightTrigger([85, 85, 85], [81, 81], range)).toBe(false); + }); + + it("never fires without a resolvable range", () => { + expect(evaluateWeightTrigger([85, 85, 85], [81, 81, 81], null)).toBe( + false, + ); + }); +}); + +describe("evaluateSleepDebtTrigger", () => { + const floor = 7; // effective greenMin; deficit means < 6.5 h. + + it("fires when 4 of 5 recorded nights clearly undershoot the floor", () => { + expect( + evaluateSleepDebtTrigger([6, 6.2, 5.9, 6.4, 7.5], floor), + ).toBe(true); + }); + + it("stays silent with only 3 deficit nights", () => { + expect(evaluateSleepDebtTrigger([6, 6, 6, 7.5, 8], floor)).toBe(false); + }); + + it("does not count near-misses inside the margin as deficits", () => { + // 6.6 h against a 7 h floor is within the 0.5 h margin. + expect( + evaluateSleepDebtTrigger([6.6, 6.6, 6.6, 6.6, 6.6], floor), + ).toBe(false); + }); + + it("requires the minimum number of recorded nights", () => { + const nights = Array.from( + { length: COACH_NUDGE_SLEEP_MIN_NIGHTS - 1 }, + () => 5, + ); + expect(evaluateSleepDebtTrigger(nights, floor)).toBe(false); + }); + + it("never fires without a resolvable floor", () => { + expect(evaluateSleepDebtTrigger([5, 5, 5, 5, 5], null)).toBe(false); + }); +}); + +describe("evaluateMeasurementGapTrigger", () => { + it("fires when an active account goes fully silent", () => { + expect(evaluateMeasurementGapTrigger(12, 0)).toBe(true); + }); + + it("stays silent for a sporadically tracking account", () => { + expect(evaluateMeasurementGapTrigger(9, 0)).toBe(false); + }); + + it("stays silent while anything still arrives", () => { + expect(evaluateMeasurementGapTrigger(12, 1)).toBe(false); + }); +}); + +describe("COACH_NUDGE_TRIGGER_GROUPS", () => { + it("maps every trigger onto its pref group", () => { + expect(COACH_NUDGE_TRIGGER_GROUPS).toEqual({ + compliance: "medication", + bp: "vitals", + score: "vitals", + weight: "vitals", + sleepDebt: "vitals", + measurementGap: "routine", + selfContext: "routine", + }); }); }); @@ -238,3 +402,189 @@ describe("evaluateSelfContextTrigger", () => { ).toBe(false); }); }); + +/** + * v1.16.5 — tick-level contracts: gate order, per-group prefs gating, + * the per-user frequency cap, and the focus personalisation. The + * Prisma client is a hand-rolled mock; flags / provider / decrypt are + * module mocks (see the vi.mock calls at the top). + */ +describe("runCoachNudgeTick — gates and prefs", () => { + const now = new Date("2026-06-10T05:15:00Z"); + const MS_PER_DAY = 24 * 60 * 60 * 1000; + + function userRow(notificationPrefs: unknown) { + return { + id: "user-1", + locale: "en", + notificationPrefs, + heightCm: null, + dateOfBirth: null, + gender: null, + thresholdsJson: null, + }; + } + + function prismaMock(overrides: { + users?: unknown[]; + intakeRows?: unknown[]; + recentNudge?: unknown; + coachFocusEncrypted?: Uint8Array | null; + }) { + return { + user: { + findMany: vi.fn(async () => overrides.users ?? []), + }, + appSettings: { + findUnique: vi.fn(async () => ({ adminAiKeyEncrypted: "k" })), + }, + pushAttempt: { + findFirst: vi.fn(async (args?: unknown) => { + void args; + return overrides.recentNudge ?? null; + }), + }, + medicationIntakeEvent: { + findMany: vi.fn(async () => overrides.intakeRows ?? []), + }, + measurement: { + findMany: vi.fn(async () => []), + count: vi.fn(async () => 1), + }, + coachUsage: { findFirst: vi.fn(async () => null) }, + userHealthProfile: { + findUnique: vi.fn(async () => ({ + coachFocusEncrypted: overrides.coachFocusEncrypted ?? null, + })), + }, + $queryRaw: vi.fn(async () => [{ days: 0 }]), + }; + } + + /** Six resolved doses, one taken → 17 %, well under the 60 % gate. */ + const failingIntakes = [ + { takenAt: new Date(), skipped: false, autoMissed: false }, + ...Array.from({ length: 5 }, () => ({ + takenAt: null, + skipped: false, + autoMissed: true, + })), + ]; + + beforeEach(() => { + vi.mocked(getAssistantFlags).mockResolvedValue({ + coach: true, + } as Awaited>); + vi.mocked(userRowHasProviderCredential).mockReturnValue(true); + }); + + it("counts a master opt-out before touching provider or cap gates", async () => { + const prisma = prismaMock({ + users: [userRow({ coach: { nudgesEnabled: false } })], + }); + const summary = await runCoachNudgeTick( + prisma as unknown as PrismaClient, + now, + { dispatch: vi.fn() }, + ); + expect(summary.skippedOptedOut).toBe(1); + expect(userRowHasProviderCredential).not.toHaveBeenCalled(); + expect(prisma.pushAttempt.findFirst).not.toHaveBeenCalled(); + }); + + it("treats every group disabled as opted out, not as no-trigger", async () => { + const prisma = prismaMock({ + users: [ + userRow({ + coach: { + nudgesEnabled: true, + nudgeMedication: false, + nudgeVitals: false, + nudgeRoutine: false, + }, + }), + ], + intakeRows: failingIntakes, + }); + const summary = await runCoachNudgeTick( + prisma as unknown as PrismaClient, + now, + { dispatch: vi.fn() }, + ); + expect(summary.skippedOptedOut).toBe(1); + expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled(); + }); + + it("checks the provider gate before the frequency cap", async () => { + vi.mocked(userRowHasProviderCredential).mockReturnValue(false); + const prisma = prismaMock({ users: [userRow(null)] }); + const summary = await runCoachNudgeTick( + prisma as unknown as PrismaClient, + now, + { dispatch: vi.fn() }, + ); + expect(summary.skippedNoProvider).toBe(1); + expect(prisma.pushAttempt.findFirst).not.toHaveBeenCalled(); + }); + + it("widens the cap cutoff to 14 days for a biweekly user", async () => { + const prisma = prismaMock({ + users: [userRow({ coach: { nudgeFrequency: "biweekly" } })], + }); + await runCoachNudgeTick(prisma as unknown as PrismaClient, now, { + dispatch: vi.fn(), + }); + const arg = vi.mocked(prisma.pushAttempt.findFirst).mock.calls[0]?.[0] as { + where: { createdAt: { gte: Date } }; + } | undefined; + expect(arg?.where.createdAt.gte.getTime()).toBe( + now.getTime() - 14 * MS_PER_DAY, + ); + }); + + it("skips a disabled group's trigger queries entirely", async () => { + const prisma = prismaMock({ + users: [ + userRow({ + coach: { nudgeMedication: false }, + }), + ], + intakeRows: failingIntakes, + }); + const summary = await runCoachNudgeTick( + prisma as unknown as PrismaClient, + now, + { dispatch: vi.fn() }, + ); + // The would-be compliance hit is never even queried; the remaining + // groups find nothing in the empty fixtures. + expect(prisma.medicationIntakeEvent.findMany).not.toHaveBeenCalled(); + expect(summary.skippedNoTrigger).toBe(1); + }); + + it("personalises the dispatched body with the decrypted Coach focus", async () => { + const dispatch = vi.fn(async (input: unknown) => { + void input; + return { dispatched: true }; + }); + const prisma = prismaMock({ + users: [userRow(null)], + intakeRows: failingIntakes, + coachFocusEncrypted: new Uint8Array([1, 2, 3]), + }); + const summary = await runCoachNudgeTick( + prisma as unknown as PrismaClient, + now, + { dispatch: dispatch as never }, + ); + expect(summary.dispatched).toBe(1); + const payload = dispatch.mock.calls[0]?.[0] as unknown as { + message: string; + metadata: { trigger: string }; + }; + expect(payload.metadata.trigger).toBe("compliance"); + expect(payload.message).toContain( + "You wanted to keep an eye on: morning blood pressure.", + ); + }); +}); diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index 6f29785d3..5b4c4281a 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -17,11 +17,14 @@ * the operator's shared key) — a nudge into a Coach that cannot * answer is worse than silence. * 4. Per-user opt-out: `notificationPrefs.coach.nudgesEnabled` - * (default ON; Settings → Notifications). - * 5. Frequency cap: at most one nudge per rolling 7 days, anchored - * on the `push_attempts` ledger (`eventType = COACH_NUDGE`, + * (default ON; Settings → Notifications). v1.16.5 adds per-group + * toggles underneath (medication / vitals / routine) — a disabled + * group's triggers are never evaluated for that user. + * 5. Frequency cap: at most one nudge per rolling window — 7 days by + * default, 14 when the user picked "biweekly" — anchored on the + * `push_attempts` ledger (`eventType = COACH_NUDGE`, * `result = "ok"`) — no new table, 90-day retention dwarfs the - * 7-day lookback, and a failed dispatch leaves the slot free. + * 14-day lookback, and a failed dispatch leaves the slot free. * * Triggers (first hit wins, evaluated cheapest-first): * - `compliance`: 7-day medication adherence < 60 % with at least @@ -36,7 +39,29 @@ * incomplete or 60+ days stale AND the user actively talks to the * Coach (a CoachUsage row in the last 14 days) — a gentle check-up * nudge to refresh the personal context the Coach reads. Presence - * is checked on the encrypted columns only; nothing is decrypted. + * is checked on the encrypted columns only; nothing is decrypted + * during trigger evaluation. + * - `weight` (v1.16.5): the weekly weight mean sits outside the + * user's effective green range AND has drifted further away from + * it than the previous week's mean (≥ 0.5 kg) — trend drift, not a + * single outlier reading. + * - `sleepDebt` (v1.16.5): at least 4 of the last 7 nights fall + * clearly (0.5 h margin) under the user's effective sleep floor, + * with at least 5 recorded nights so a sparse week stays silent. + * - `measurementGap` (v1.16.5): a previously active account + * (measurements on ≥ 10 distinct days in the preceding 3 weeks) + * records NOTHING for 7 straight days — an abrupt stop, whether + * disengagement or a silently broken sync, is worth a check-in. + * + * Trigger groups (v1.16.5, per-group opt-outs in the prefs blob): + * medication → compliance; vitals → bp / score / weight / sleepDebt; + * routine → measurementGap / selfContext. + * + * Personalisation (v1.16.5): when the user wrote a Coach focus in the + * self-context questionnaire, the nudge body appends a deterministic + * reference to it ("you wanted to keep an eye on …"). The focus is + * decrypted only AFTER a trigger fired and a nudge is actually about + * to dispatch — fail-closed, a decrypt error just drops the suffix. */ import type { PrismaClient } from "@/generated/prisma/client"; import { getAssistantFlags } from "@/lib/feature-flags"; @@ -46,7 +71,8 @@ import { type ThresholdOverridesJson, } from "@/lib/analytics/effective-range"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; -import { resolveCoachNudgesEnabled } from "@/lib/validations/notification-prefs"; +import { resolveCoachNudgePrefs } from "@/lib/validations/notification-prefs"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import type { Locale } from "@/lib/i18n/config"; import { defaultLocale, locales } from "@/lib/i18n/config"; @@ -59,7 +85,12 @@ export const COACH_NUDGE_QUEUE = "coach-nudge"; /** 05:15 Europe/Berlin — after the 04:45–04:55 score crons settled. */ export const COACH_NUDGE_CRON = "15 5 * * *"; -/** Frequency cap: one nudge per user per rolling week. */ +/** + * Frequency cap default: one nudge per user per rolling week. v1.16.5 — + * the per-user `nudgeFrequency` pref ("weekly" | "biweekly") can widen + * this to 14 days; the constant remains the default and the unit-test + * anchor. + */ export const COACH_NUDGE_MIN_INTERVAL_DAYS = 7; /** Compliance trigger: 7-day rate below this fires (0..1). */ @@ -76,8 +107,49 @@ export const COACH_NUDGE_SCORE_MIN_SAMPLES = 3; export const COACH_NUDGE_SELF_CONTEXT_STALE_DAYS = 60; /** Self-context trigger: Coach usage within this window counts as active. */ export const COACH_NUDGE_COACH_ACTIVE_DAYS = 14; +/** Weight trigger: minimum readings per weekly window. */ +export const COACH_NUDGE_WEIGHT_MIN_READINGS = 3; +/** Weight trigger: minimum kg the weekly mean must drift AWAY from the range. */ +export const COACH_NUDGE_WEIGHT_DRIFT_KG = 0.5; +/** Sleep trigger: minimum recorded nights in the 7-day window. */ +export const COACH_NUDGE_SLEEP_MIN_NIGHTS = 5; +/** Sleep trigger: nights under the floor required to fire. */ +export const COACH_NUDGE_SLEEP_DEFICIT_NIGHTS = 4; +/** Sleep trigger: a night must undershoot the floor by this margin (h). */ +export const COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H = 0.5; +/** Gap trigger: distinct active days required in the prior 3 weeks. */ +export const COACH_NUDGE_GAP_MIN_ACTIVE_DAYS = 10; +/** Gap trigger: length of the silent window that fires (days). */ +export const COACH_NUDGE_GAP_SILENT_DAYS = 7; +/** Gap trigger: activity lookback BEFORE the silent window (days). */ +export const COACH_NUDGE_GAP_LOOKBACK_DAYS = 21; +/** Personalisation: max focus characters quoted in the nudge body. */ +export const COACH_NUDGE_FOCUS_MAX_CHARS = 80; -export type CoachNudgeTrigger = "compliance" | "bp" | "score" | "selfContext"; +export type CoachNudgeTrigger = + | "compliance" + | "bp" + | "score" + | "selfContext" + | "weight" + | "sleepDebt" + | "measurementGap"; + +/** v1.16.5 — per-group opt-outs map triggers onto three pref toggles. */ +export type CoachNudgeTriggerGroup = "medication" | "vitals" | "routine"; + +export const COACH_NUDGE_TRIGGER_GROUPS: Record< + CoachNudgeTrigger, + CoachNudgeTriggerGroup +> = { + compliance: "medication", + bp: "vitals", + score: "vitals", + weight: "vitals", + sleepDebt: "vitals", + measurementGap: "routine", + selfContext: "routine", +}; export interface CoachNudgeSummary { candidatesScanned: number; @@ -192,43 +264,159 @@ export function evaluateScoreTrigger( return mean(priorValues) - mean(recentValues) >= COACH_NUDGE_SCORE_DROP; } +/** + * v1.16.5 — weight trend drift against the user's effective green + * range. Two conditions, both required, so a single heavy breakfast + * never nudges: + * 1. the recent 7-day mean sits OUTSIDE the green range, and + * 2. it has drifted at least `COACH_NUDGE_WEIGHT_DRIFT_KG` further + * away from the range than the prior week's mean — direction-aware + * via distance-to-range, so it fires for moving away on either + * side and stays silent while the user converges back. + * Both windows need `COACH_NUDGE_WEIGHT_MIN_READINGS` readings; no + * resolvable range (no height, no override) never fires. + */ +export function evaluateWeightTrigger( + recentValues: number[], + priorValues: number[], + range: { greenMin: number; greenMax: number } | null, +): boolean { + if (range === null) return false; + if ( + recentValues.length < COACH_NUDGE_WEIGHT_MIN_READINGS || + priorValues.length < COACH_NUDGE_WEIGHT_MIN_READINGS + ) { + return false; + } + const mean = (values: number[]) => + values.reduce((sum, v) => sum + v, 0) / values.length; + const distance = (v: number) => + Math.max(0, range.greenMin - v, v - range.greenMax); + const recentDistance = distance(mean(recentValues)); + const priorDistance = distance(mean(priorValues)); + return ( + recentDistance > 0 && + recentDistance - priorDistance >= COACH_NUDGE_WEIGHT_DRIFT_KG + ); +} + +/** + * v1.16.5 — sleep-deficit series. Fires when at least + * `COACH_NUDGE_SLEEP_DEFICIT_NIGHTS` of the recorded nights undershoot + * the effective sleep floor by a clear margin (0.5 h — a 6:55 night + * against a 7 h floor is not a deficit pattern). Requires at least + * `COACH_NUDGE_SLEEP_MIN_NIGHTS` recorded nights in the 7-day window so + * a sparsely tracked week stays silent; no resolvable floor never fires. + */ +export function evaluateSleepDebtTrigger( + nightlyHours: number[], + greenMin: number | null, +): boolean { + if (greenMin === null) return false; + if (nightlyHours.length < COACH_NUDGE_SLEEP_MIN_NIGHTS) return false; + const deficits = nightlyHours.filter( + (h) => h < greenMin - COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H, + ).length; + return deficits >= COACH_NUDGE_SLEEP_DEFICIT_NIGHTS; +} + +/** + * v1.16.5 — measurement-gap series: a previously ACTIVE account that + * abruptly stops recording anything. Active means measurements on at + * least `COACH_NUDGE_GAP_MIN_ACTIVE_DAYS` distinct days across the + * three weeks BEFORE the silent window; the trigger fires only when the + * last `COACH_NUDGE_GAP_SILENT_DAYS` days hold zero measurements. A + * sporadically tracking account never qualifies as "active", so it is + * never nagged for being itself. + */ +export function evaluateMeasurementGapTrigger( + priorActiveDays: number, + recentMeasurementCount: number, +): boolean { + return ( + priorActiveDays >= COACH_NUDGE_GAP_MIN_ACTIVE_DAYS && + recentMeasurementCount === 0 + ); +} + /** * Build the localised push payload for a trigger. Bodies stay * deliberately vague on numbers — a lock screen is not the place for * health figures; the Coach conversation carries the detail. + * + * v1.16.5 — `coachFocus` (the user's own "watch this" line from the + * self-context questionnaire, already decrypted by the caller) appends + * a deterministic personal suffix to the body. The focus is the user's + * own words about what to pay attention to — not a reading, not a + * figure — so quoting it on the lock screen stays within the + * no-health-figures rule. Clamped to `COACH_NUDGE_FOCUS_MAX_CHARS`. + * The self-context check-up nudge skips the suffix: quoting the focus + * while asking the user to refresh it would contradict itself. */ export function buildCoachNudgePayload( trigger: CoachNudgeTrigger, locale: string | null | undefined, + coachFocus?: string | null, ): { title: string; body: string } { const t = getServerTranslator(resolveLocale(locale)).t; - switch (trigger) { - case "compliance": - return { - title: t("coachNudges.complianceTitle"), - body: t("coachNudges.complianceBody"), - }; - case "bp": - return { - title: t("coachNudges.bpTitle"), - body: t("coachNudges.bpBody"), - }; - case "score": - return { - title: t("coachNudges.scoreTitle"), - body: t("coachNudges.scoreBody"), - }; - case "selfContext": - return { - title: t("coachNudges.selfContextTitle"), - body: t("coachNudges.selfContextBody"), - }; - } + const base = ((): { title: string; body: string } => { + switch (trigger) { + case "compliance": + return { + title: t("coachNudges.complianceTitle"), + body: t("coachNudges.complianceBody"), + }; + case "bp": + return { + title: t("coachNudges.bpTitle"), + body: t("coachNudges.bpBody"), + }; + case "score": + return { + title: t("coachNudges.scoreTitle"), + body: t("coachNudges.scoreBody"), + }; + case "selfContext": + return { + title: t("coachNudges.selfContextTitle"), + body: t("coachNudges.selfContextBody"), + }; + case "weight": + return { + title: t("coachNudges.weightTitle"), + body: t("coachNudges.weightBody"), + }; + case "sleepDebt": + return { + title: t("coachNudges.sleepDebtTitle"), + body: t("coachNudges.sleepDebtBody"), + }; + case "measurementGap": + return { + title: t("coachNudges.measurementGapTitle"), + body: t("coachNudges.measurementGapBody"), + }; + } + })(); + + const focus = coachFocus?.trim(); + if (!focus || trigger === "selfContext") return base; + const clamped = + focus.length > COACH_NUDGE_FOCUS_MAX_CHARS + ? `${focus.slice(0, COACH_NUDGE_FOCUS_MAX_CHARS - 1).trimEnd()}…` + : focus; + return { + title: base.title, + body: `${base.body} ${t("coachNudges.focusSuffix", { focus: clamped })}`, + }; } /** * Evaluate the triggers for one user. Exported for tests; the tick - * below feeds it the pre-fetched rows. + * below feeds it the pre-fetched rows. v1.16.5 — `groups` carries the + * per-group opt-outs from the prefs blob: a disabled group's triggers + * are skipped entirely (no queries, no evaluation). Defaults to + * all-enabled so existing callers keep the old behaviour. */ export async function findTriggerForUser( prisma: PrismaClient, @@ -240,109 +428,216 @@ export async function findTriggerForUser( thresholdsJson: unknown; }, now: Date, + groups: Record = { + medication: true, + vitals: true, + routine: true, + }, ): Promise { const sevenDaysAgo = new Date(now.getTime() - 7 * MS_PER_DAY); const fourteenDaysAgo = new Date(now.getTime() - 14 * MS_PER_DAY); - // 1) Medication compliance (7 d). `autoMissed` rides along so the - // trigger can restrict its denominator to resolved slots — a - // still-open pending is not a miss yet. - const intakeRows = await prisma.medicationIntakeEvent.findMany({ - where: { - userId: user.id, - deletedAt: null, - scheduledFor: { gte: sevenDaysAgo, lte: now }, - }, - select: { takenAt: true, skipped: true, autoMissed: true }, - }); - if (evaluateComplianceTrigger(intakeRows)) return "compliance"; - - // 2) Systolic weekly mean vs the user's effective target. - const systolic = await prisma.measurement.findMany({ - where: { - userId: user.id, - type: "BLOOD_PRESSURE_SYS", - deletedAt: null, - measuredAt: { gte: sevenDaysAgo, lte: now }, - }, - select: { value: true }, - }); - if (systolic.length >= COACH_NUDGE_BP_MIN_READINGS) { - const range = getEffectiveRange( - "BLOOD_PRESSURE_SYS", + const effectiveRange = ( + metric: "BLOOD_PRESSURE_SYS" | "WEIGHT" | "SLEEP_DURATION", + ) => + getEffectiveRange( + metric, { heightCm: user.heightCm, dateOfBirth: user.dateOfBirth, gender: user.gender, }, (user.thresholdsJson ?? null) as ThresholdOverridesJson | null, - ); - if ( - evaluateBpTrigger( - systolic.map((m) => m.value), - range.range?.greenMax ?? null, - ) - ) { - return "bp"; + ).range; + + // ── medication group ─────────────────────────────────────────────── + if (groups.medication) { + // 1) Medication compliance (7 d). `autoMissed` rides along so the + // trigger can restrict its denominator to resolved slots — a + // still-open pending is not a miss yet. + const intakeRows = await prisma.medicationIntakeEvent.findMany({ + where: { + userId: user.id, + deletedAt: null, + scheduledFor: { gte: sevenDaysAgo, lte: now }, + }, + select: { takenAt: true, skipped: true, autoMissed: true }, + }); + if (evaluateComplianceTrigger(intakeRows)) return "compliance"; + } + + // ── vitals group ─────────────────────────────────────────────────── + if (groups.vitals) { + // 2) Systolic weekly mean vs the user's effective target. + const systolic = await prisma.measurement.findMany({ + where: { + userId: user.id, + type: "BLOOD_PRESSURE_SYS", + deletedAt: null, + measuredAt: { gte: sevenDaysAgo, lte: now }, + }, + select: { value: true }, + }); + if (systolic.length >= COACH_NUDGE_BP_MIN_READINGS) { + if ( + evaluateBpTrigger( + systolic.map((m) => m.value), + effectiveRange("BLOOD_PRESSURE_SYS")?.greenMax ?? null, + ) + ) { + return "bp"; + } + } + + // 3) Recovery score falling sharply week-over-week. + const scores = await prisma.measurement.findMany({ + where: { + userId: user.id, + type: "RECOVERY_SCORE", + deletedAt: null, + measuredAt: { gte: fourteenDaysAgo, lte: now }, + }, + select: { value: true, measuredAt: true }, + }); + const recent = scores + .filter((m) => m.measuredAt >= sevenDaysAgo) + .map((m) => m.value); + const prior = scores + .filter((m) => m.measuredAt < sevenDaysAgo) + .map((m) => m.value); + if (evaluateScoreTrigger(recent, prior)) return "score"; + + // 4) Weight weekly mean drifting away from the effective range. + const weightRange = effectiveRange("WEIGHT"); + if (weightRange) { + const weights = await prisma.measurement.findMany({ + where: { + userId: user.id, + type: "WEIGHT", + deletedAt: null, + measuredAt: { gte: fourteenDaysAgo, lte: now }, + }, + select: { value: true, measuredAt: true }, + }); + const recentWeights = weights + .filter((m) => m.measuredAt >= sevenDaysAgo) + .map((m) => m.value); + const priorWeights = weights + .filter((m) => m.measuredAt < sevenDaysAgo) + .map((m) => m.value); + if ( + evaluateWeightTrigger(recentWeights, priorWeights, { + greenMin: weightRange.greenMin, + greenMax: weightRange.greenMax, + }) + ) { + return "weight"; + } + } + + // 5) Sleep-deficit series: per-night hours over the last 7 days, + // longest record per calendar night (multi-source rows collapse + // to the fullest one). + const sleepRows = await prisma.measurement.findMany({ + where: { + userId: user.id, + type: "SLEEP_DURATION", + deletedAt: null, + measuredAt: { gte: sevenDaysAgo, lte: now }, + }, + select: { value: true, measuredAt: true }, + }); + if (sleepRows.length > 0) { + const byNight = new Map(); + for (const row of sleepRows) { + const night = row.measuredAt.toISOString().slice(0, 10); + const prev = byNight.get(night); + if (prev === undefined || row.value > prev) { + byNight.set(night, row.value); + } + } + if ( + evaluateSleepDebtTrigger( + [...byNight.values()], + effectiveRange("SLEEP_DURATION")?.greenMin ?? null, + ) + ) { + return "sleepDebt"; + } } } - // 3) Recovery score falling sharply week-over-week. - const scores = await prisma.measurement.findMany({ - where: { - userId: user.id, - type: "RECOVERY_SCORE", - deletedAt: null, - measuredAt: { gte: fourteenDaysAgo, lte: now }, - }, - select: { value: true, measuredAt: true }, - }); - const recent = scores - .filter((m) => m.measuredAt >= sevenDaysAgo) - .map((m) => m.value); - const prior = scores - .filter((m) => m.measuredAt < sevenDaysAgo) - .map((m) => m.value); - if (evaluateScoreTrigger(recent, prior)) return "score"; - - // 4) Self-context incomplete / stale while the Coach is in active - // use. Presence-only reads on the encrypted columns — nothing is - // decrypted in this job. - const coachActiveCutoff = new Date( - now.getTime() - COACH_NUDGE_COACH_ACTIVE_DAYS * MS_PER_DAY, - ); - const recentUsage = await prisma.coachUsage.findFirst({ - where: { userId: user.id, updatedAt: { gte: coachActiveCutoff } }, - orderBy: { updatedAt: "desc" }, - select: { updatedAt: true }, - }); - if (recentUsage) { - const profileRow = await prisma.userHealthProfile.findUnique({ - where: { userId: user.id }, - select: { - aboutMeEncrypted: true, - conditionsEncrypted: true, - allergiesEncrypted: true, - coachFocusEncrypted: true, - updatedAt: true, + // ── routine group ────────────────────────────────────────────────── + if (groups.routine) { + // 6) Measurement gap: an active account going silent for a week. + // The recent count is cheap; the distinct-day count over the + // prior three weeks runs as one aggregate instead of pulling + // every row (dense intraday sources would make that thousands). + const recentCount = await prisma.measurement.count({ + where: { + userId: user.id, + deletedAt: null, + measuredAt: { gte: sevenDaysAgo, lte: now }, }, }); - const triggered = evaluateSelfContextTrigger( - { - profile: profileRow - ? { - hasAboutMe: profileRow.aboutMeEncrypted !== null, - hasConditions: profileRow.conditionsEncrypted !== null, - hasAllergies: profileRow.allergiesEncrypted !== null, - hasCoachFocus: profileRow.coachFocusEncrypted !== null, - updatedAt: profileRow.updatedAt, - } - : null, - lastCoachUseAt: recentUsage.updatedAt, - }, - now, + if (recentCount === 0) { + const gapWindowStart = new Date( + sevenDaysAgo.getTime() - COACH_NUDGE_GAP_LOOKBACK_DAYS * MS_PER_DAY, + ); + const activeDayRows = await prisma.$queryRaw<{ days: number }[]>` + SELECT COUNT(DISTINCT (measured_at AT TIME ZONE 'UTC')::date)::int AS days + FROM measurements + WHERE user_id = ${user.id} + AND deleted_at IS NULL + AND measured_at >= ${gapWindowStart} + AND measured_at < ${sevenDaysAgo} + `; + if ( + evaluateMeasurementGapTrigger(activeDayRows[0]?.days ?? 0, recentCount) + ) { + return "measurementGap"; + } + } + + // 7) Self-context incomplete / stale while the Coach is in active + // use. Presence-only reads on the encrypted columns — nothing is + // decrypted during trigger evaluation. + const coachActiveCutoff = new Date( + now.getTime() - COACH_NUDGE_COACH_ACTIVE_DAYS * MS_PER_DAY, ); - if (triggered) return "selfContext"; + const recentUsage = await prisma.coachUsage.findFirst({ + where: { userId: user.id, updatedAt: { gte: coachActiveCutoff } }, + orderBy: { updatedAt: "desc" }, + select: { updatedAt: true }, + }); + if (recentUsage) { + const profileRow = await prisma.userHealthProfile.findUnique({ + where: { userId: user.id }, + select: { + aboutMeEncrypted: true, + conditionsEncrypted: true, + allergiesEncrypted: true, + coachFocusEncrypted: true, + updatedAt: true, + }, + }); + const triggered = evaluateSelfContextTrigger( + { + profile: profileRow + ? { + hasAboutMe: profileRow.aboutMeEncrypted !== null, + hasConditions: profileRow.conditionsEncrypted !== null, + hasAllergies: profileRow.allergiesEncrypted !== null, + hasCoachFocus: profileRow.coachFocusEncrypted !== null, + updatedAt: profileRow.updatedAt, + } + : null, + lastCoachUseAt: recentUsage.updatedAt, + }, + now, + ); + if (triggered) return "selfContext"; + } } return null; @@ -407,15 +702,19 @@ export async function runCoachNudgeTick( }); const adminKeyConfigured = !!settings?.adminAiKeyEncrypted; - const capCutoff = new Date( - now.getTime() - COACH_NUDGE_MIN_INTERVAL_DAYS * MS_PER_DAY, - ); - for (const user of users) { summary.candidatesScanned += 1; try { - // Gate 4 — per-user opt-out. - if (!resolveCoachNudgesEnabled(user.notificationPrefs)) { + // Gate 4 — per-user opt-out: master switch plus the v1.16.5 + // per-group toggles. A user with the master ON but every group + // OFF counts as opted out, not as "no trigger". + const nudgePrefs = resolveCoachNudgePrefs(user.notificationPrefs); + if ( + !nudgePrefs.enabled || + (!nudgePrefs.groups.medication && + !nudgePrefs.groups.vitals && + !nudgePrefs.groups.routine) + ) { summary.skippedOptedOut += 1; continue; } @@ -426,10 +725,14 @@ export async function runCoachNudgeTick( continue; } - // Gate 5 — one nudge per rolling week, anchored on the - // push-attempts ledger. A delivered nudge writes an `ok` row per - // succeeding channel; a fully failed dispatch leaves the slot - // free so tomorrow's tick can retry. + // Gate 5 — one nudge per rolling window (7 d default, 14 d when + // the user picked "biweekly"), anchored on the push-attempts + // ledger. A delivered nudge writes an `ok` row per succeeding + // channel; a fully failed dispatch leaves the slot free so + // tomorrow's tick can retry. + const capCutoff = new Date( + now.getTime() - nudgePrefs.minIntervalDays * MS_PER_DAY, + ); const recentNudge = await prisma.pushAttempt.findFirst({ where: { userId: user.id, @@ -444,13 +747,41 @@ export async function runCoachNudgeTick( continue; } - const trigger = await findTriggerForUser(prisma, user, now); + const trigger = await findTriggerForUser( + prisma, + user, + now, + nudgePrefs.groups, + ); if (!trigger) { summary.skippedNoTrigger += 1; continue; } - const { title, body } = buildCoachNudgePayload(trigger, user.locale); + // Personalisation — decrypt the user's Coach focus only now that + // a nudge is actually about to dispatch. Fail-closed: a missing + // row or an undecryptable payload just drops the suffix. + let coachFocus: string | null = null; + if (trigger !== "selfContext") { + try { + const profileRow = await prisma.userHealthProfile.findUnique({ + where: { userId: user.id }, + select: { coachFocusEncrypted: true }, + }); + if (profileRow?.coachFocusEncrypted) { + coachFocus = + decryptFromBytes(profileRow.coachFocusEncrypted).trim() || null; + } + } catch { + coachFocus = null; + } + } + + const { title, body } = buildCoachNudgePayload( + trigger, + user.locale, + coachFocus, + ); const outcome = await dispatchImpl({ eventType: "COACH_NUDGE", userId: user.id, diff --git a/src/lib/validations/__tests__/notification-prefs.test.ts b/src/lib/validations/__tests__/notification-prefs.test.ts index fdc570eb3..efaf6f12a 100644 --- a/src/lib/validations/__tests__/notification-prefs.test.ts +++ b/src/lib/validations/__tests__/notification-prefs.test.ts @@ -7,6 +7,7 @@ import { isMedicationReminderClientManaged, notificationPrefsSchema, parseNotificationPrefs, + resolveCoachNudgePrefs, resolveDeviceDelivery, resolveMoodReminderHour, resolveNotificationPrefs, @@ -91,7 +92,13 @@ describe("parseNotificationPrefs", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }); }); @@ -100,7 +107,13 @@ describe("parseNotificationPrefs", () => { medication: { clientManaged: false, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }); }); @@ -111,7 +124,13 @@ describe("parseNotificationPrefs", () => { medication: { clientManaged: true, deliveryDefault: "client" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }); }); @@ -138,7 +157,13 @@ describe("resolveNotificationPrefs (deep-merge)", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }); }); @@ -153,7 +178,13 @@ describe("resolveNotificationPrefs (deep-merge)", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }); }); @@ -165,7 +196,13 @@ describe("resolveNotificationPrefs (deep-merge)", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }); }); @@ -177,7 +214,13 @@ describe("resolveNotificationPrefs (deep-merge)", () => { medication: { clientManaged: true, deliveryDefault: "client" }, mood: { reminderHour: DEFAULT_MOOD_REMINDER_HOUR }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }); }); @@ -190,7 +233,13 @@ describe("resolveNotificationPrefs (deep-merge)", () => { medication: { clientManaged: true, deliveryDefault: "server" }, mood: { reminderHour: 8 }, cycle: { clientManaged: false }, - coach: { nudgesEnabled: true }, + coach: { + nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", + }, }); }); @@ -335,3 +384,58 @@ describe("resolveDeviceDelivery — per-device override", () => { ).toBe(false); }); }); + +describe("resolveCoachNudgePrefs — v1.16.5 nudge cron gate", () => { + it("resolves the documented defaults for a null row", () => { + expect(resolveCoachNudgePrefs(null)).toEqual({ + enabled: true, + groups: { medication: true, vitals: true, routine: true }, + minIntervalDays: 7, + }); + }); + + it("keeps legacy rows (master switch only) fully enabled", () => { + const out = resolveCoachNudgePrefs({ coach: { nudgesEnabled: true } }); + expect(out.groups).toEqual({ + medication: true, + vitals: true, + routine: true, + }); + expect(out.minIntervalDays).toBe(7); + }); + + it("carries the master opt-out", () => { + expect( + resolveCoachNudgePrefs({ coach: { nudgesEnabled: false } }).enabled, + ).toBe(false); + }); + + it("resolves per-group opt-outs independently", () => { + const out = resolveCoachNudgePrefs({ + coach: { nudgeMedication: false, nudgeRoutine: false }, + }); + expect(out.groups).toEqual({ + medication: false, + vitals: true, + routine: false, + }); + // Group opt-outs do not touch the master switch. + expect(out.enabled).toBe(true); + }); + + it("maps the biweekly frequency pref onto 14 days", () => { + expect( + resolveCoachNudgePrefs({ coach: { nudgeFrequency: "biweekly" } }) + .minIntervalDays, + ).toBe(14); + }); + + it("rejects an unknown frequency by falling back to defaults", () => { + // A drifted enum value fails the schema; the whole blob resolves + // to the documented defaults rather than throwing in the cron. + expect( + resolveCoachNudgePrefs({ coach: { nudgeFrequency: "daily" } }) + .minIntervalDays, + ).toBe(7); + }); +}); diff --git a/src/lib/validations/notification-prefs.ts b/src/lib/validations/notification-prefs.ts index 6da77ea66..130b1ee1a 100644 --- a/src/lib/validations/notification-prefs.ts +++ b/src/lib/validations/notification-prefs.ts @@ -81,6 +81,18 @@ const cyclePrefsSchema = z const coachPrefsSchema = z .object({ nudgesEnabled: z.boolean(), + /** + * v1.16.5 — per-trigger-group opt-outs underneath the master + * switch. Groups, not individual triggers, so the UI stays three + * comprehensible toggles as the trigger list grows: + * medication → compliance; vitals → bp / score / weight / sleep; + * routine → measurement-gap / self-context check-up. + */ + nudgeMedication: z.boolean(), + nudgeVitals: z.boolean(), + nudgeRoutine: z.boolean(), + /** v1.16.5 — frequency cap: one nudge per 7 or per 14 days. */ + nudgeFrequency: z.enum(["weekly", "biweekly"]), }) .partial(); @@ -137,6 +149,14 @@ export interface NotificationPrefs { * 05:15 nudge cron skips the user when `false`. */ nudgesEnabled: boolean; + /** v1.16.5 — medication-group triggers (compliance). Default on. */ + nudgeMedication: boolean; + /** v1.16.5 — vitals-group triggers (bp / score / weight / sleep). */ + nudgeVitals: boolean; + /** v1.16.5 — routine-group triggers (measurement gap / self-context). */ + nudgeRoutine: boolean; + /** v1.16.5 — cap interval: "weekly" (7 d) or "biweekly" (14 d). */ + nudgeFrequency: "weekly" | "biweekly"; }; } @@ -165,6 +185,10 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = { }, coach: { nudgesEnabled: true, + nudgeMedication: true, + nudgeVitals: true, + nudgeRoutine: true, + nudgeFrequency: "weekly", }, }; @@ -302,6 +326,36 @@ export function resolveCoachNudgesEnabled(raw: unknown): boolean { return parseNotificationPrefs(raw).coach.nudgesEnabled; } +/** + * v1.16.5 — fully-resolved Coach-nudge view for the cron: the master + * switch, the three per-group toggles, and the frequency cap mapped to + * days. One call so the nudge tick reads a single shape instead of + * threading the whole prefs object. + */ +export interface CoachNudgePrefs { + enabled: boolean; + groups: { + medication: boolean; + vitals: boolean; + routine: boolean; + }; + /** 7 for "weekly", 14 for "biweekly". */ + minIntervalDays: number; +} + +export function resolveCoachNudgePrefs(raw: unknown): CoachNudgePrefs { + const coach = parseNotificationPrefs(raw).coach; + return { + enabled: coach.nudgesEnabled, + groups: { + medication: coach.nudgeMedication, + vitals: coach.nudgeVitals, + routine: coach.nudgeRoutine, + }, + minIntervalDays: coach.nudgeFrequency === "biweekly" ? 14 : 7, + }; +} + function cloneDefaults(): NotificationPrefs { return { medication: { ...DEFAULT_NOTIFICATION_PREFS.medication }, diff --git a/src/lib/validations/schedule-revision.ts b/src/lib/validations/schedule-revision.ts new file mode 100644 index 000000000..a6d7f8bf2 --- /dev/null +++ b/src/lib/validations/schedule-revision.ts @@ -0,0 +1,62 @@ +/** + * v1.16.5 — request schema for + * `POST /api/medications/{id}/schedule-revisions`. + * + * Lives outside the route file so the OpenAPI registry can import it + * without dragging `@/lib/db` into the generator script — the same + * split as `src/lib/validations/invite.ts`. + * + * The endpoint appends a MANUAL schedule era ("the medication dosed at + * 07:00/19:00 from March to June, before I tracked it here"). The body + * carries the era bounds as instants plus the daily times-of-day the + * era dosed at; the route derives the snapshot payload entry + * (`FREQ=DAILY`, window pulled to min/max of the times) so the era + * minter reads it exactly like a write-path archive. + */ +import { z } from "zod/v4"; + +/** HH:mm wall-clock literal — mirrors `validations/medication.ts`. */ +const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/; + +export const scheduleRevisionCreateSchema = z + .object({ + /** Inclusive start instant of the manual era. */ + validFrom: z.iso + .datetime({ offset: true }) + .describe("Inclusive start instant of the manual era (ISO 8601)."), + /** + * Exclusive end instant — the moment the next plan took over. Must + * lie after `validFrom` and at or before the start of the live + * schedule era (the newest archived revision's `validUntil`, or + * "now" when no revision exists yet). + */ + validUntil: z.iso + .datetime({ offset: true }) + .describe( + "Exclusive end instant of the manual era (ISO 8601). Must precede the current plan.", + ), + /** Daily dose times the era ran at. Same cap as live schedules. */ + timesOfDay: z + .array(z.string().regex(timeRegex, "Format: HH:mm")) + .min(1) + .max(8) + .describe( + "Daily dose times (HH:mm, user local) the era ran at. 1–8 entries.", + ), + }) + .refine( + (body) => new Date(body.validFrom).getTime() < new Date(body.validUntil).getTime(), + { + message: "validFrom must lie before validUntil", + path: ["validUntil"], + }, + ) + .meta({ + id: "CreateScheduleRevisionRequest", + description: + "Manual schedule-era payload: era bounds plus the daily dose times that were live during the era.", + }); + +export type ScheduleRevisionCreateInput = z.infer< + typeof scheduleRevisionCreateSchema +>;