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 (
- {/* 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 (
+
+ {Array.from({ length: total }, (_, i) => (
+ current && "bg-muted-foreground/25",
+ )}
+ />
+ ))}
+
+ );
+}
+
+export interface GuidedQuestionBubbleProps {
+ question: string;
+ progress: { current: number; total: number };
+ /** Live question: actions render and the typing reveal plays. */
+ current?: boolean;
+ onSkip?: () => void;
+ onLater?: () => void;
+ onDismissRemaining?: () => void;
+ /** Disables the actions (in-flight dismiss request). */
+ actionsDisabled?: boolean;
+}
+
+export function GuidedQuestionBubble({
+ question,
+ progress,
+ current = false,
+ onSkip,
+ onLater,
+ onDismissRemaining,
+ actionsDisabled = false,
+}: GuidedQuestionBubbleProps) {
+ const { t } = useTranslations();
+
+ // Scripted-bot rhythm: the current question holds the shared
+ // typing-dots indicator for a beat before the text reveals. SSR and
+ // reduced-motion users get the text immediately; answered bubbles
+ // never replay the reveal.
+ const [revealed, setRevealed] = useState(() => {
+ if (!current) return true;
+ if (typeof window === "undefined") return true;
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ });
+ useEffect(() => {
+ if (revealed) return;
+ const handle = window.setTimeout(() => setRevealed(true), 650);
+ return () => window.clearTimeout(handle);
+ }, [revealed]);
+
+ return (
+