mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(coach): a guided dialog for follow-ups and nudges that know their owner
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.
This commit is contained in:
1 parent
f13aa684f9
commit
a62e00cfcd
25 files changed
+3199
-377
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
+69
-5
@@ -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?",
|
||||
|
||||
+69
-5
@@ -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?",
|
||||
|
||||
+69
-5
@@ -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?",
|
||||
|
||||
+69
-5
@@ -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 ?",
|
||||
|
||||
+69
-5
@@ -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?",
|
||||
|
||||
+69
-5
@@ -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?",
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe("<GuidedQuestionsCard>", () => {
|
||||
it("renders the plural offer with the count and all three choices", () => {
|
||||
const html = render(
|
||||
<GuidedQuestionsCard
|
||||
count={3}
|
||||
onStart={noop}
|
||||
onLater={noop}
|
||||
onDismissAll={noop}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<GuidedQuestionsCard
|
||||
count={1}
|
||||
onStart={noop}
|
||||
onLater={noop}
|
||||
onDismissAll={noop}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<GuidedQuestionsCard
|
||||
count={2}
|
||||
disabled
|
||||
onStart={noop}
|
||||
onLater={noop}
|
||||
onDismissAll={noop}
|
||||
/>,
|
||||
);
|
||||
expect(html.match(/disabled=""/g)?.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<GuidedQuestionBubble>", () => {
|
||||
it("renders progress + question text for the current question with actions", () => {
|
||||
const html = render(
|
||||
<GuidedQuestionBubble
|
||||
question="Do you have any chronic conditions?"
|
||||
progress={{ current: 1, total: 3 }}
|
||||
current
|
||||
onSkip={noop}
|
||||
onLater={noop}
|
||||
onDismissRemaining={noop}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<GuidedQuestionBubble
|
||||
question="Do you have any chronic conditions?"
|
||||
progress={{ current: 1, total: 3 }}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-state="answered"');
|
||||
expect(html).not.toContain('data-slot="coach-guided-actions"');
|
||||
});
|
||||
|
||||
it("localises the progress label", () => {
|
||||
const html = render(
|
||||
<GuidedQuestionBubble
|
||||
question="Hast du chronische Erkrankungen?"
|
||||
progress={{ current: 2, total: 3 }}
|
||||
/>,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Frage 2 von 3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<GuidedSummaryBubble>", () => {
|
||||
it("recaps answered + adopted counts and links to the self-context", () => {
|
||||
const html = render(
|
||||
<GuidedSummaryBubble answered={2} adopted={1} total={3} />,
|
||||
);
|
||||
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(
|
||||
<GuidedSummaryBubble answered={1} adopted={0} total={3} />,
|
||||
);
|
||||
expect(html).toContain("nothing new added to your self-context");
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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: <span data-testid={key} />,
|
||||
});
|
||||
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("<MessageThread> interleaved guided bubbles", () => {
|
||||
it("renders an unanchored item at the tail even on an empty thread", () => {
|
||||
const html = render(
|
||||
<MessageThread
|
||||
conversation={null}
|
||||
interleaved={[
|
||||
{
|
||||
key: "q1",
|
||||
anchorAnswer: null,
|
||||
node: <div data-slot="guided-test-bubble">First question?</div>,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
// 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(
|
||||
<MessageThread
|
||||
conversation={baseConversation}
|
||||
interleaved={[
|
||||
{
|
||||
key: "q1",
|
||||
anchorAnswer: "Why was BP higher on Monday?",
|
||||
node: <div data-slot="guided-test-bubble">Guided question?</div>,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string | null>(
|
||||
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" ? (
|
||||
<GuidedSummaryBubble
|
||||
answered={item.summary?.answered ?? 0}
|
||||
adopted={item.summary?.adopted ?? 0}
|
||||
total={item.summary?.total ?? 0}
|
||||
/>
|
||||
) : (
|
||||
<GuidedQuestionBubble
|
||||
question={item.question ?? ""}
|
||||
progress={item.progress ?? { current: 1, total: 1 }}
|
||||
current={item.current}
|
||||
actionsDisabled={send.isStreaming || dismissQuestions.isPending}
|
||||
onSkip={
|
||||
item.current
|
||||
? () => dispatchGuided({ type: "SKIP" })
|
||||
: undefined
|
||||
}
|
||||
onLater={
|
||||
item.current
|
||||
? () => dispatchGuided({ type: "EXIT" })
|
||||
: undefined
|
||||
}
|
||||
onDismissRemaining={
|
||||
item.current
|
||||
? () =>
|
||||
dismissQuestions.mutate(undefined, {
|
||||
onSuccess: () => dispatchGuided({ type: "EXIT" }),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-conversation"
|
||||
@@ -250,7 +343,13 @@ export function CoachConversation({
|
||||
surface === "page" ? (
|
||||
<HistoryRail
|
||||
activeId={currentConversationId}
|
||||
onSelect={(id) => 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={
|
||||
<div>
|
||||
{/* 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 ? (
|
||||
<SelfContextAdoptOffer
|
||||
question={pendingAdopt.question}
|
||||
answer={pendingAdopt.answer}
|
||||
onDismiss={() => 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 ? (
|
||||
<GuidedQuestionsCard
|
||||
count={pendingQuestions.length}
|
||||
disabled={send.isStreaming || dismissQuestions.isPending}
|
||||
onStart={() =>
|
||||
dispatchGuided({ type: "START", questions: pendingQuestions })
|
||||
}
|
||||
onLater={() => dispatchGuided({ type: "LATER" })}
|
||||
onDismissAll={() =>
|
||||
dismissQuestions.mutate(undefined, {
|
||||
onSuccess: () => dispatchGuided({ type: "LATER" }),
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<SelfContextChips
|
||||
disabled={send.isStreaming}
|
||||
onPick={(question) => {
|
||||
setActiveChipQuestion(question);
|
||||
setInputValue(`${question}\n`);
|
||||
}}
|
||||
/>
|
||||
<CoachInput
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
@@ -291,6 +407,11 @@ export function CoachConversation({
|
||||
disabled={send.isStreaming}
|
||||
isStreaming={send.isStreaming}
|
||||
autoFocusOnOpen={autoFocusComposer}
|
||||
placeholder={
|
||||
guided.phase === "asking"
|
||||
? t("insights.coach.guided.answerPlaceholder")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
@@ -311,6 +432,8 @@ export function CoachConversation({
|
||||
onSelect={(id) => {
|
||||
setCurrentConversationId(id);
|
||||
setHistoryTrayOpen(false);
|
||||
setPendingAdopt(null);
|
||||
dispatchGuided({ type: "RESET" });
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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<HTMLTextAreaElement | null>(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
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="from-dracula-purple to-dracula-pink mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
|
||||
>
|
||||
<Icon className="text-background size-3.5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<span aria-hidden="true" className="inline-flex items-center gap-1">
|
||||
{Array.from({ length: total }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
i + 1 < current && "bg-dracula-purple",
|
||||
i + 1 === current && "ring-dracula-purple bg-dracula-purple/40 ring-1",
|
||||
i + 1 > current && "bg-muted-foreground/25",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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<boolean>(() => {
|
||||
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 (
|
||||
<div
|
||||
data-slot="coach-guided-question"
|
||||
data-state={current ? "current" : "answered"}
|
||||
className="flex items-start gap-2.5"
|
||||
>
|
||||
<GuidedAvatar icon={MessageCircleQuestion} />
|
||||
<div className="flex max-w-[calc(80%-2.625rem)] min-w-0 flex-col gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
"border-dracula-purple/30 bg-dracula-purple/10 text-foreground",
|
||||
"rounded-xl rounded-tl-sm border px-3.5 py-2.5",
|
||||
"text-sm leading-relaxed",
|
||||
)}
|
||||
>
|
||||
{revealed ? (
|
||||
<>
|
||||
<p
|
||||
data-slot="coach-guided-progress"
|
||||
className="text-dracula-purple mb-1 flex items-center gap-2 text-[10px] font-semibold tracking-wider uppercase"
|
||||
>
|
||||
{t("insights.coach.guided.progress", {
|
||||
current: progress.current,
|
||||
total: progress.total,
|
||||
})}
|
||||
<ProgressDots
|
||||
current={progress.current}
|
||||
total={progress.total}
|
||||
/>
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap">{question}</p>
|
||||
</>
|
||||
) : (
|
||||
<TypingDots label={t("insights.coach.thinking")} />
|
||||
)}
|
||||
</div>
|
||||
{current && revealed && (
|
||||
<div
|
||||
data-slot="coach-guided-actions"
|
||||
className="flex flex-wrap items-center gap-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-guided-skip"
|
||||
disabled={actionsDisabled}
|
||||
onClick={onSkip}
|
||||
className={guidedActionClass}
|
||||
>
|
||||
{t("insights.coach.guided.skip")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-guided-later"
|
||||
disabled={actionsDisabled}
|
||||
onClick={onLater}
|
||||
className={guidedActionClass}
|
||||
>
|
||||
{t("insights.coach.guided.later")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-guided-dismiss"
|
||||
disabled={actionsDisabled}
|
||||
onClick={onDismissRemaining}
|
||||
className={guidedActionClass}
|
||||
>
|
||||
{t("insights.coach.guided.dismissRemaining")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface GuidedSummaryBubbleProps {
|
||||
answered: number;
|
||||
adopted: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function GuidedSummaryBubble({
|
||||
answered,
|
||||
adopted,
|
||||
total,
|
||||
}: GuidedSummaryBubbleProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-guided-summary"
|
||||
className="flex items-start gap-2.5"
|
||||
>
|
||||
<GuidedAvatar icon={ClipboardCheck} />
|
||||
<div
|
||||
className={cn(
|
||||
"border-dracula-purple/30 bg-dracula-purple/10 text-foreground",
|
||||
"max-w-[calc(80%-2.625rem)] min-w-0 rounded-xl rounded-tl-sm border px-3.5 py-2.5",
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-medium">
|
||||
{t("insights.coach.guided.summaryTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("insights.coach.guided.summaryAnswered", { answered, total })}
|
||||
{" · "}
|
||||
{adopted > 0
|
||||
? t("insights.coach.guided.summaryAdopted", { adopted })
|
||||
: t("insights.coach.guided.summaryAdoptedNone")}
|
||||
</p>
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
data-slot="coach-guided-summary-link"
|
||||
className={cn(
|
||||
"text-dracula-purple mt-2 inline-flex min-h-8 items-center gap-1 text-xs font-medium",
|
||||
"hover:underline focus-visible:ring-dracula-purple/40 rounded focus-visible:ring-2 focus-visible:outline-none",
|
||||
)}
|
||||
>
|
||||
{t("insights.coach.guided.summaryLink")}
|
||||
<ArrowRight className="size-3" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.16.5 — entry card for the guided clarifying-questions flow.
|
||||
*
|
||||
* Replaces the v1.16.0 loose composer chips: when pending self-context
|
||||
* questions exist, this quiet card sits above the composer and offers
|
||||
* the guided sequence instead of scattering tappable chips. Three
|
||||
* choices, mirroring common scripted-onboarding chat patterns:
|
||||
*
|
||||
* - "Answer now" starts the in-chat sequence (machine START).
|
||||
* - "Later" hides the offer for this session; the questions stay
|
||||
* pending and the card returns on the next mount.
|
||||
* - "Don't ask again" dismisses every pending question server-side
|
||||
* (existing DELETE with empty body).
|
||||
*
|
||||
* Renders nothing when no questions pend — zero vertical cost.
|
||||
*/
|
||||
import { MessageCircleQuestion } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export interface GuidedQuestionsCardProps {
|
||||
/** Pending-question count (1–3); the parent hides the card at 0. */
|
||||
count: number;
|
||||
onStart: () => void;
|
||||
onLater: () => void;
|
||||
onDismissAll: () => void;
|
||||
/** Disabled while a reply streams or the dismiss request is in flight. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function GuidedQuestionsCard({
|
||||
count,
|
||||
onStart,
|
||||
onLater,
|
||||
onDismissAll,
|
||||
disabled = false,
|
||||
}: GuidedQuestionsCardProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-guided-offer"
|
||||
className={cn(
|
||||
"border-dracula-purple/30 from-dracula-purple/10 to-dracula-pink/5",
|
||||
"mb-2 rounded-lg border bg-gradient-to-r px-3 py-2.5",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="from-dracula-purple to-dracula-pink mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full bg-gradient-to-br"
|
||||
>
|
||||
<MessageCircleQuestion className="text-background size-3.5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{count === 1
|
||||
? t("insights.coach.guided.offerTitleOne")
|
||||
: t("insights.coach.guided.offerTitle", { count })}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
{t("insights.coach.guided.offerBody")}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-guided-offer-start"
|
||||
disabled={disabled}
|
||||
onClick={onStart}
|
||||
className={cn(
|
||||
"bg-dracula-purple/15 text-dracula-purple inline-flex min-h-8 items-center rounded-full px-3 text-xs font-medium",
|
||||
"hover:bg-dracula-purple/25 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
|
||||
"disabled:opacity-60",
|
||||
)}
|
||||
>
|
||||
{t("insights.coach.guided.offerStart")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-guided-offer-later"
|
||||
disabled={disabled}
|
||||
onClick={onLater}
|
||||
className={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",
|
||||
)}
|
||||
>
|
||||
{t("insights.coach.guided.offerLater")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="coach-guided-offer-dismiss"
|
||||
disabled={disabled}
|
||||
onClick={onDismissAll}
|
||||
className={cn(
|
||||
"text-muted-foreground/70 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",
|
||||
)}
|
||||
>
|
||||
{t("insights.coach.guided.offerDismiss")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* v1.16.5 — guided clarifying-questions flow, the V2 of the v1.16.0
|
||||
* composer chips.
|
||||
*
|
||||
* After the user saves Settings → AI → "About me", up to 3 pending
|
||||
* clarifying questions live encrypted on the profile
|
||||
* (`/api/coach/about-me/questions`). V1 surfaced them as loose chips
|
||||
* the user had to pick apart; V2 walks them one at a time inside the
|
||||
* chat: an entry card offers the sequence, the Coach asks each
|
||||
* question as a deterministic bubble (no model call), the user answers
|
||||
* in the normal composer, the existing `/adopt` offer folds the answer
|
||||
* into the matching self-context field, and a closing summary says
|
||||
* what was taken over.
|
||||
*
|
||||
* This module is the whole client-side state machine, kept pure so the
|
||||
* transitions can be unit-tested without a DOM:
|
||||
*
|
||||
* idle ──START──▶ asking ──ANSWER_SUBMITTED──▶ answered
|
||||
* │ │ ▲ │
|
||||
* LATER SKIP└──────TURN_COMPLETE────────┘
|
||||
* │ │ (next question, or…)
|
||||
* ▼ ├──EXIT/SKIP-on-last──▶ summary (answers exist)
|
||||
* done ◀────────────┴──────────────────────▶ done (none answered)
|
||||
*
|
||||
* Server contract stays additive-free: answering dismisses that one
|
||||
* question (existing DELETE), "skip" / "later" leave questions pending
|
||||
* for the next session, "don't ask again" dismisses all (existing
|
||||
* DELETE with empty body). No new schema, no new endpoint.
|
||||
*/
|
||||
|
||||
/** Lifecycle of one answered question's adopt offer. */
|
||||
export type GuidedAdoption =
|
||||
| "pending"
|
||||
| "adopted"
|
||||
| "duplicate"
|
||||
| "declined"
|
||||
| "failed";
|
||||
|
||||
export interface GuidedOutcome {
|
||||
/** Index of the question in the session snapshot (progress label). */
|
||||
index: number;
|
||||
question: string;
|
||||
/** The composer message that answered it (thread anchor). */
|
||||
answer: string;
|
||||
adoption: GuidedAdoption;
|
||||
}
|
||||
|
||||
export type GuidedState =
|
||||
| { phase: "idle" }
|
||||
| { phase: "done" }
|
||||
| {
|
||||
/**
|
||||
* `asking` — the current question bubble is live and the next
|
||||
* composer submit answers it. `answered` — the answer was sent
|
||||
* and the assistant turn is still streaming; no current bubble.
|
||||
*/
|
||||
phase: "asking" | "answered";
|
||||
/** Snapshot taken at START; pending-question edits mid-flow don't reshuffle the sequence. */
|
||||
questions: string[];
|
||||
index: number;
|
||||
outcomes: GuidedOutcome[];
|
||||
}
|
||||
| { phase: "summary"; questions: string[]; outcomes: GuidedOutcome[] };
|
||||
|
||||
export const GUIDED_IDLE: GuidedState = { phase: "idle" };
|
||||
|
||||
export type GuidedEvent =
|
||||
| { type: "START"; questions: string[] }
|
||||
/** Entry-card "later": hide the offer for this session, keep questions pending. */
|
||||
| { type: "LATER" }
|
||||
/** Composer submit while `asking` — the message answers the current question. */
|
||||
| { type: "ANSWER_SUBMITTED"; answer: string }
|
||||
/** The assistant turn for the answer finished; advance the sequence. */
|
||||
| { type: "TURN_COMPLETE" }
|
||||
/** Skip the current question (stays pending server-side). */
|
||||
| { type: "SKIP" }
|
||||
/** Leave the flow ("later" / "don't ask again" mid-sequence). */
|
||||
| { type: "EXIT" }
|
||||
/** The adopt offer for an answered question settled. */
|
||||
| {
|
||||
type: "ADOPTION_SETTLED";
|
||||
index: number;
|
||||
adoption: Exclude<GuidedAdoption, "pending">;
|
||||
}
|
||||
/** New chat / conversation switch / drawer close — drop the session. */
|
||||
| { type: "RESET" };
|
||||
|
||||
/**
|
||||
* Close the sequence: answered questions earn the summary bubble;
|
||||
* a flow abandoned before any answer just ends (the entry card will
|
||||
* re-offer the still-pending questions next session).
|
||||
*/
|
||||
function finish(state: {
|
||||
questions: string[];
|
||||
outcomes: GuidedOutcome[];
|
||||
}): GuidedState {
|
||||
return state.outcomes.length > 0
|
||||
? { phase: "summary", questions: state.questions, outcomes: state.outcomes }
|
||||
: { phase: "done" };
|
||||
}
|
||||
|
||||
export function guidedReducer(
|
||||
state: GuidedState,
|
||||
event: GuidedEvent,
|
||||
): GuidedState {
|
||||
switch (event.type) {
|
||||
case "RESET":
|
||||
return GUIDED_IDLE;
|
||||
|
||||
case "START": {
|
||||
if (state.phase !== "idle" && state.phase !== "done") return state;
|
||||
if (event.questions.length === 0) return state;
|
||||
return {
|
||||
phase: "asking",
|
||||
questions: [...event.questions],
|
||||
index: 0,
|
||||
outcomes: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "LATER":
|
||||
return state.phase === "idle" ? { phase: "done" } : state;
|
||||
|
||||
case "ANSWER_SUBMITTED": {
|
||||
if (state.phase !== "asking") return state;
|
||||
return {
|
||||
...state,
|
||||
phase: "answered",
|
||||
outcomes: [
|
||||
...state.outcomes,
|
||||
{
|
||||
index: state.index,
|
||||
question: state.questions[state.index],
|
||||
answer: event.answer,
|
||||
adoption: "pending",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case "TURN_COMPLETE": {
|
||||
if (state.phase !== "answered") return state;
|
||||
const next = state.index + 1;
|
||||
if (next < state.questions.length) {
|
||||
return { ...state, phase: "asking", index: next };
|
||||
}
|
||||
return finish(state);
|
||||
}
|
||||
|
||||
case "SKIP": {
|
||||
if (state.phase !== "asking") return state;
|
||||
const next = state.index + 1;
|
||||
if (next < state.questions.length) {
|
||||
return { ...state, index: next };
|
||||
}
|
||||
return finish(state);
|
||||
}
|
||||
|
||||
case "EXIT": {
|
||||
if (state.phase !== "asking") return state;
|
||||
return finish(state);
|
||||
}
|
||||
|
||||
case "ADOPTION_SETTLED": {
|
||||
if (
|
||||
state.phase !== "asking" &&
|
||||
state.phase !== "answered" &&
|
||||
state.phase !== "summary"
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
const outcomes = state.outcomes.map((o) =>
|
||||
o.index === event.index && o.adoption === "pending"
|
||||
? { ...o, adoption: event.adoption }
|
||||
: o,
|
||||
);
|
||||
return { ...state, outcomes };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Count of answers actually written into the self-context. */
|
||||
export function countAdopted(outcomes: GuidedOutcome[]): number {
|
||||
return outcomes.filter((o) => o.adoption === "adopted").length;
|
||||
}
|
||||
|
||||
/**
|
||||
* One in-thread bubble the guided flow contributes. Answered questions
|
||||
* anchor immediately BEFORE the user message that answered them
|
||||
* (`anchorAnswer` = that message's content) so the transcript reads
|
||||
* question → answer → reply; the current question and the summary
|
||||
* carry `anchorAnswer: null` and render at the thread tail.
|
||||
*/
|
||||
export interface GuidedThreadItem {
|
||||
key: string;
|
||||
kind: "question" | "summary";
|
||||
/** kind=question */
|
||||
question?: string;
|
||||
progress?: { current: number; total: number };
|
||||
/** kind=question: live question — render actions + typing reveal. */
|
||||
current?: boolean;
|
||||
/** kind=summary */
|
||||
summary?: { answered: number; adopted: number; total: number };
|
||||
anchorAnswer: string | null;
|
||||
}
|
||||
|
||||
export function deriveThreadItems(state: GuidedState): GuidedThreadItem[] {
|
||||
if (state.phase === "idle" || state.phase === "done") return [];
|
||||
|
||||
const total = state.questions.length;
|
||||
const items: GuidedThreadItem[] = state.outcomes.map((o) => ({
|
||||
key: `guided-q-${o.index}`,
|
||||
kind: "question",
|
||||
question: o.question,
|
||||
progress: { current: o.index + 1, total },
|
||||
anchorAnswer: o.answer,
|
||||
}));
|
||||
|
||||
if (state.phase === "asking") {
|
||||
items.push({
|
||||
key: `guided-q-${state.index}`,
|
||||
kind: "question",
|
||||
question: state.questions[state.index],
|
||||
progress: { current: state.index + 1, total },
|
||||
current: true,
|
||||
anchorAnswer: null,
|
||||
});
|
||||
}
|
||||
|
||||
if (state.phase === "summary") {
|
||||
items.push({
|
||||
key: "guided-summary",
|
||||
kind: "summary",
|
||||
summary: {
|
||||
answered: state.outcomes.length,
|
||||
adopted: countAdopted(state.outcomes),
|
||||
total,
|
||||
},
|
||||
anchorAnswer: null,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -59,6 +59,74 @@ export interface MessageThreadProps {
|
||||
optimisticUser?: CoachOptimisticUserMessage | null;
|
||||
/** Empty-state copy when no conversation is loaded yet. */
|
||||
emptyHint?: string;
|
||||
/**
|
||||
* v1.16.5 — locally-rendered bubbles the guided clarifying-questions
|
||||
* flow interleaves with the persisted history (deterministic Coach
|
||||
* questions + the closing summary). Items anchored on an answer
|
||||
* render immediately before the user message that answered them;
|
||||
* unanchored items render at the thread tail. See `placeInterleaved`.
|
||||
*/
|
||||
interleaved?: InterleavedThreadItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.5 — one locally-rendered thread bubble contributed by the
|
||||
* guided clarifying-questions flow. The thread owns only the placement;
|
||||
* the node's behaviour lives with the flow in `coach-conversation`.
|
||||
*/
|
||||
export interface InterleavedThreadItem {
|
||||
key: string;
|
||||
/**
|
||||
* Content of the user message this item precedes (a guided question
|
||||
* renders above its answer). `null` → render at the thread tail
|
||||
* (the current question / the summary).
|
||||
*/
|
||||
anchorAnswer: string | null;
|
||||
node: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure placement for interleaved items, exported for unit tests.
|
||||
* Items are chronological by construction (the guided flow emits them
|
||||
* in question order), so a single forward pointer suffices: each
|
||||
* anchored item consumes the first remaining user message whose
|
||||
* content equals its anchor. Anchors that never match (e.g. an errored
|
||||
* turn whose message was never persisted) fall through to the tail so
|
||||
* no bubble is ever silently dropped.
|
||||
*/
|
||||
export function placeInterleaved(
|
||||
items: InterleavedThreadItem[],
|
||||
messages: { id: string; role: string; content: string }[],
|
||||
optimisticContent: string | null,
|
||||
): {
|
||||
before: Map<string, InterleavedThreadItem>;
|
||||
beforeOptimistic: InterleavedThreadItem[];
|
||||
tail: InterleavedThreadItem[];
|
||||
} {
|
||||
const anchored = items.filter((i) => i.anchorAnswer !== null);
|
||||
const before = new Map<string, InterleavedThreadItem>();
|
||||
let p = 0;
|
||||
for (const m of messages) {
|
||||
if (p >= anchored.length) break;
|
||||
if (m.role === "user" && m.content === anchored[p].anchorAnswer) {
|
||||
before.set(m.id, anchored[p]);
|
||||
p += 1;
|
||||
}
|
||||
}
|
||||
const beforeOptimistic: InterleavedThreadItem[] = [];
|
||||
if (
|
||||
p < anchored.length &&
|
||||
optimisticContent !== null &&
|
||||
optimisticContent === anchored[p].anchorAnswer
|
||||
) {
|
||||
beforeOptimistic.push(anchored[p]);
|
||||
p += 1;
|
||||
}
|
||||
const tail = [
|
||||
...anchored.slice(p),
|
||||
...items.filter((i) => i.anchorAnswer === null),
|
||||
];
|
||||
return { before, beforeOptimistic, tail };
|
||||
}
|
||||
|
||||
function isPinnedToBottom(el: HTMLElement, slack = 64): boolean {
|
||||
@@ -112,6 +180,7 @@ export function MessageThread({
|
||||
streaming,
|
||||
optimisticUser,
|
||||
emptyHint,
|
||||
interleaved,
|
||||
}: MessageThreadProps) {
|
||||
const { t } = useTranslations();
|
||||
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -129,6 +198,8 @@ export function MessageThread({
|
||||
() => conversation?.messages ?? [],
|
||||
[conversation?.messages],
|
||||
);
|
||||
// v1.16.5 — locally-rendered guided bubbles (see `placeInterleaved`).
|
||||
const interleavedItems = interleaved ?? [];
|
||||
// v1.4.20.1 — once the SSE stream emits `done`, the route's
|
||||
// invalidate-then-refetch pulls the persisted assistant message into
|
||||
// `conversation.messages`. The streaming bubble was still rendering
|
||||
@@ -187,6 +258,15 @@ export function MessageThread({
|
||||
return true;
|
||||
})();
|
||||
|
||||
// v1.16.5 — slot the guided bubbles around the persisted history,
|
||||
// the optimistic user bubble, and the streaming tail. Cheap on every
|
||||
// render: at most a handful of items over a single message walk.
|
||||
const placement = placeInterleaved(
|
||||
interleavedItems,
|
||||
messages,
|
||||
optimisticActive && optimisticUser ? optimisticUser.content : null,
|
||||
);
|
||||
|
||||
// Track scroll position so we don't yank the viewport when the user
|
||||
// is browsing earlier turns.
|
||||
useEffect(() => {
|
||||
@@ -203,6 +283,7 @@ export function MessageThread({
|
||||
// when the user was already at the bottom. v1.4.25 W5 — the
|
||||
// optimistic user bubble counts as a new message; scroll on its
|
||||
// localId so the user sees their own bubble land at the bottom.
|
||||
// v1.16.5 — guided bubbles count as new messages for the auto-scroll.
|
||||
useEffect(() => {
|
||||
const el = scrollerRef.current;
|
||||
if (!el) return;
|
||||
@@ -210,7 +291,12 @@ export function MessageThread({
|
||||
// v1.4.43 W5-H5 — respect `prefers-reduced-motion`.
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: scrollBehaviorForUser() });
|
||||
}
|
||||
}, [messages.length, streaming?.content, optimisticUser?.localId]);
|
||||
}, [
|
||||
messages.length,
|
||||
streaming?.content,
|
||||
optimisticUser?.localId,
|
||||
interleavedItems.length,
|
||||
]);
|
||||
|
||||
// v1.4.27 R3d MB4 / CF-74 — re-pin to the bottom when the
|
||||
// visual viewport shrinks (typically the soft keyboard opening on
|
||||
@@ -234,7 +320,12 @@ export function MessageThread({
|
||||
return () => vv.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
if (messages.length === 0 && !streamingActive && !optimisticActive) {
|
||||
if (
|
||||
messages.length === 0 &&
|
||||
!streamingActive &&
|
||||
!optimisticActive &&
|
||||
interleavedItems.length === 0
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
data-slot="coach-message-thread"
|
||||
@@ -269,15 +360,20 @@ export function MessageThread({
|
||||
// twin during the 150ms grace window so the streaming bubble
|
||||
// stays alone on slow connections.
|
||||
if (m.id === suppressedTwinId) return null;
|
||||
// v1.16.5 — a guided question renders directly above the user
|
||||
// message that answered it.
|
||||
const guidedBefore = placement.before.get(m.id);
|
||||
return (
|
||||
<ChatBubble
|
||||
key={m.id}
|
||||
role={m.role}
|
||||
content={m.content}
|
||||
metricSource={m.metricSource}
|
||||
providerType={m.providerType}
|
||||
messageId={m.id}
|
||||
/>
|
||||
<Fragment key={m.id}>
|
||||
{guidedBefore?.node}
|
||||
<ChatBubble
|
||||
role={m.role}
|
||||
content={m.content}
|
||||
metricSource={m.metricSource}
|
||||
providerType={m.providerType}
|
||||
messageId={m.id}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{/* v1.4.25 W5 — optimistic user bubble surfaces between the
|
||||
@@ -285,6 +381,10 @@ export function MessageThread({
|
||||
the visible order matches the user's mental model. The
|
||||
send-hook drops it as soon as the SSE `done` frame fires +
|
||||
the invalidate-refetch lands the persisted twin. */}
|
||||
{/* v1.16.5 — guided question whose answer is still optimistic-only. */}
|
||||
{placement.beforeOptimistic.map((i) => (
|
||||
<Fragment key={i.key}>{i.node}</Fragment>
|
||||
))}
|
||||
{optimisticActive && optimisticUser && (
|
||||
<ChatBubble
|
||||
key={optimisticUser.localId}
|
||||
@@ -308,6 +408,11 @@ export function MessageThread({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* v1.16.5 — thread tail: the current guided question and/or the
|
||||
closing summary follow the last completed turn. */}
|
||||
{placement.tail.map((i) => (
|
||||
<Fragment key={i.key}>{i.node}</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<span
|
||||
data-slot="coach-typing-indicator"
|
||||
|
||||
@@ -28,12 +28,23 @@ export interface SelfContextAdoptOfferProps {
|
||||
answer: string;
|
||||
/** Remove the strip (declined, or confirmation elapsed). */
|
||||
onDismiss: () => 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",
|
||||
|
||||
@@ -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 (
|
||||
<div data-slot="coach-self-context-chips" className="mb-2">
|
||||
<p className="text-muted-foreground mb-1.5 flex items-center gap-1.5 text-[11px]">
|
||||
<MessageCircleQuestion
|
||||
className="text-dracula-purple size-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t("insights.coach.selfContextChipsLabel")}
|
||||
</p>
|
||||
<ul className="flex flex-wrap gap-1.5">
|
||||
{questions.map((question) => (
|
||||
<li
|
||||
key={question}
|
||||
role="group"
|
||||
aria-label={question}
|
||||
className={cn(
|
||||
"border-dracula-purple/30 bg-dracula-purple/10 inline-flex max-w-full items-stretch overflow-hidden rounded-full border",
|
||||
"transition-colors",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
data-slot="coach-self-context-chip"
|
||||
onClick={() => {
|
||||
onPick(question);
|
||||
dismiss.mutate(question);
|
||||
}}
|
||||
title={question}
|
||||
className={cn(
|
||||
"text-foreground min-h-9 min-w-0 truncate py-1.5 pr-1 pl-3 text-left text-xs",
|
||||
"hover:bg-dracula-purple/15 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
|
||||
"disabled:opacity-60",
|
||||
)}
|
||||
>
|
||||
{question}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={t("insights.coach.selfContextChipDismiss")}
|
||||
data-slot="coach-self-context-chip-dismiss"
|
||||
onClick={() => dismiss.mutate(question)}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground flex items-center px-2",
|
||||
"hover:bg-dracula-purple/15 focus-visible:ring-dracula-purple/40 focus-visible:ring-2 focus-visible:outline-none",
|
||||
"disabled:opacity-60",
|
||||
)}
|
||||
>
|
||||
<X className="size-3" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 `<MoodReminderCard>` (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<GroupField, { label: string; desc: string }> = {
|
||||
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<boolean | null>(null);
|
||||
const [optimistic, setOptimistic] = useState<Partial<CoachPrefsShape>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(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<CoachPrefsShape>,
|
||||
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 (
|
||||
<section
|
||||
aria-labelledby="settings-coach-nudge-title"
|
||||
@@ -132,6 +196,63 @@ export function CoachNudgeCard({
|
||||
</label>
|
||||
}
|
||||
/>
|
||||
{enabled && (
|
||||
<div className="mt-4 space-y-3 pl-7">
|
||||
<p className="text-sm font-medium">
|
||||
{t("notifications.coachNudge.groupsLabel")}
|
||||
</p>
|
||||
{GROUP_FIELDS.map((field) => (
|
||||
<div
|
||||
key={field}
|
||||
className="flex min-h-11 items-center justify-between gap-3"
|
||||
data-testid={`coach-nudge-group-${field}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm">{t(GROUP_I18N[field].label)}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(GROUP_I18N[field].desc)}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={resolved[field]}
|
||||
onCheckedChange={(next) => patchCoach({ [field]: next }, null)}
|
||||
disabled={!isAuthenticated || saving}
|
||||
aria-label={t(GROUP_I18N[field].label)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex min-h-11 items-center gap-3">
|
||||
<label
|
||||
htmlFor="coach-nudge-frequency"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("notifications.coachNudge.frequencyLabel")}
|
||||
</label>
|
||||
<NativeSelect
|
||||
id="coach-nudge-frequency"
|
||||
className="w-auto"
|
||||
value={resolved.nudgeFrequency}
|
||||
disabled={!isAuthenticated || saving}
|
||||
onChange={(e) =>
|
||||
patchCoach(
|
||||
{
|
||||
nudgeFrequency: e.target.value as "weekly" | "biweekly",
|
||||
},
|
||||
null,
|
||||
)
|
||||
}
|
||||
aria-label={t("notifications.coachNudge.frequencyAria")}
|
||||
>
|
||||
<option value="weekly">
|
||||
{t("notifications.coachNudge.frequencyWeekly")}
|
||||
</option>
|
||||
<option value="biweekly">
|
||||
{t("notifications.coachNudge.frequencyBiweekly")}
|
||||
</option>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{msg && (
|
||||
<p
|
||||
role="status"
|
||||
|
||||
@@ -12,17 +12,38 @@
|
||||
* - payload: title + body resolve per locale and fall back to the
|
||||
* app default for null / unknown locales.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import {
|
||||
COACH_NUDGE_COMPLIANCE_MIN_DOSES,
|
||||
COACH_NUDGE_FOCUS_MAX_CHARS,
|
||||
COACH_NUDGE_SCORE_DROP,
|
||||
COACH_NUDGE_SLEEP_MIN_NIGHTS,
|
||||
COACH_NUDGE_TRIGGER_GROUPS,
|
||||
buildCoachNudgePayload,
|
||||
evaluateBpTrigger,
|
||||
evaluateComplianceTrigger,
|
||||
evaluateMeasurementGapTrigger,
|
||||
evaluateScoreTrigger,
|
||||
evaluateSelfContextTrigger,
|
||||
evaluateSleepDebtTrigger,
|
||||
evaluateWeightTrigger,
|
||||
runCoachNudgeTick,
|
||||
} from "../coach-nudge";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
import { userRowHasProviderCredential } from "@/lib/ai/provider";
|
||||
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
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<ReturnType<typeof getAssistantFlags>>);
|
||||
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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
+461
-130
@@ -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<CoachNudgeTriggerGroup, boolean> = {
|
||||
medication: true,
|
||||
vitals: true,
|
||||
routine: true,
|
||||
},
|
||||
): Promise<CoachNudgeTrigger | null> {
|
||||
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<string, number>();
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
Reference in new issue
Block a user