From 12f4bc24e83da39ddbf88d8b95faf2c33e18f44c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 21:17:51 +0200 Subject: [PATCH] feat(coach): evidence-based cadence suggestions with one-tap in-chat reminder The Coach can append a ---SUGGEST-REMINDER--- sentinel to propose a measurement cadence (weight daily, the ESH/AHA 7-2-2 home-BP protocol, structured glucose). The server resolves the cadence from a closed catalog and emits an additive 'suggestion' SSE frame the chat renders as a one-tap action card; accepting it creates a MeasurementReminder with origin COACH through the same engine the Vorsorge surface uses (server-authoritative schedule, finite course window self-expires via endsOn). Non-naggy by construction: a frequency-cap cooldown, dismissal memory, a master opt-out under Coach settings, and an explicit 'I measure enough' stop path, all in the coach-prefs blob. Triple dedup at suggest, render, and create so a cadence is never double-suggested or double-created, and suggestions for a disabled module never surface. RHR/HRV stay passive -- the Coach surfaces the baseline, it never nudges to 'go measure' them. The reminder DTO now echoes origin and endsOn for the iOS client. --- .gitleaks.toml | 3 + docs/api/openapi.yaml | 95 ++++++++- messages/de.json | 21 ++ messages/en.json | 21 ++ messages/es.json | 21 ++ messages/fr.json | 21 ++ messages/it.json | 21 ++ messages/pl.json | 21 ++ .../api/coach/reminder-suggestions/route.ts | 193 ++++++++++++++++++ src/app/api/insights/chat/route.ts | 80 +++++++- .../api/measurement-reminders/[id]/route.ts | 3 + .../__tests__/message-thread.test.tsx | 10 + .../insights/coach-panel/message-thread.tsx | 19 ++ .../coach-panel/reminder-suggestion-card.tsx | 151 ++++++++++++++ .../insights/coach-panel/use-coach.ts | 22 ++ .../settings/coach-prefs-section.tsx | 36 ++++ .../ai/coach/__tests__/suggest-gate.test.ts | 138 +++++++++++++ .../coach/__tests__/suggest-reminder.test.ts | 96 +++++++++ src/lib/ai/coach/suggest-gate.ts | 116 +++++++++++ src/lib/ai/coach/suggest-reminder.ts | 188 +++++++++++++++++ src/lib/ai/coach/system-prompt.ts | 72 +++++++ src/lib/ai/coach/types.ts | 31 +++ .../__tests__/dto.test.ts | 44 ++++ src/lib/measurement-reminders/dto.ts | 13 ++ src/lib/measurement-reminders/scheduling.ts | 9 +- .../validations/__tests__/coach-prefs.test.ts | 31 +++ src/lib/validations/coach-prefs.ts | 43 ++++ .../validations/coach-reminder-suggestion.ts | 24 +++ src/lib/validations/measurement-reminders.ts | 4 +- 29 files changed, 1538 insertions(+), 9 deletions(-) create mode 100644 src/app/api/coach/reminder-suggestions/route.ts create mode 100644 src/components/insights/coach-panel/reminder-suggestion-card.tsx create mode 100644 src/lib/ai/coach/__tests__/suggest-gate.test.ts create mode 100644 src/lib/ai/coach/__tests__/suggest-reminder.test.ts create mode 100644 src/lib/ai/coach/suggest-gate.ts create mode 100644 src/lib/ai/coach/suggest-reminder.ts create mode 100644 src/lib/measurement-reminders/__tests__/dto.test.ts create mode 100644 src/lib/validations/coach-reminder-suggestion.ts diff --git a/.gitleaks.toml b/.gitleaks.toml index 6905bb139..1b98529ce 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -25,6 +25,9 @@ regexes = [ # i18n caption keys for the briefing-driven trend charts are dotted message # keys, not secrets — one trips the generic-api-key entropy heuristic. '''insights\.trendsRow\.caption''', + # Coach cadence-suggestion label keys are dotted message keys, not secrets; + # one trips the generic-api-key entropy heuristic. + '''coach\.reminderSuggestion\.cadence''', ] # Specific commit fingerprints from prior false-positive findings (kept for reproducibility). diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e349dc30a..1a150f1e0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -3041,6 +3041,28 @@ paths: - medication - mobility - environment + reminderSuggestions: + type: object + properties: + enabled: + default: true + type: boolean + stopped: + default: false + type: boolean + dismissedCadences: + default: [] + maxItems: 32 + type: array + items: + type: string + maxLength: 64 + lastSuggestedAt: + default: null + anyOf: + - type: string + maxLength: 40 + - type: "null" responses: "200": description: Saved preferences echoed back. @@ -12227,6 +12249,34 @@ components: - medication - mobility - environment + reminderSuggestions: + type: object + properties: + enabled: + default: true + type: boolean + stopped: + default: false + type: boolean + dismissedCadences: + default: [] + maxItems: 32 + type: array + items: + type: string + maxLength: 64 + lastSuggestedAt: + default: null + anyOf: + - type: string + maxLength: 40 + - type: "null" + required: + - enabled + - stopped + - dismissedCadences + - lastSuggestedAt + additionalProperties: false required: - tone - verbosity @@ -12311,6 +12361,34 @@ components: - medication - mobility - environment + reminderSuggestions: + type: object + properties: + enabled: + default: true + type: boolean + stopped: + default: false + type: boolean + dismissedCadences: + default: [] + maxItems: 32 + type: array + items: + type: string + maxLength: 64 + lastSuggestedAt: + default: null + anyOf: + - type: string + maxLength: 40 + - type: "null" + required: + - enabled + - stopped + - dismissedCadences + - lastSuggestedAt + additionalProperties: false required: - tone - verbosity @@ -16941,6 +17019,17 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + endsOn: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + origin: + type: string + enum: + - VORSORGE + - COACH notifyHour: type: integer minimum: -9007199254740991 @@ -16978,6 +17067,8 @@ components: - intervalDays - rrule - anchorDate + - endsOn + - origin - notifyHour - location - nextDueAt @@ -16987,7 +17078,9 @@ components: - updatedAt additionalProperties: false description: A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries - measurementType=null and resolves only on a manual satisfy. + measurementType=null and resolves only on a manual satisfy. origin distinguishes a user-created (VORSORGE) + reminder from one minted by a Coach cadence suggestion (COACH); endsOn bounds a finite course window (null = + open-ended). MeasurementReminderCreatedEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index c8f36560c..168aa07ad 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6619,5 +6619,26 @@ "label": "Medikamente", "description": "Pläne, Einnahmen und Compliance." } + }, + "coach": { + "reminderSuggestion": { + "title": "Erinnerung einrichten?", + "accept": "Erinnerung setzen", + "dismiss": "Jetzt nicht", + "stop": "Ich messe genug", + "accepted": "Erinnerung gesetzt", + "dismissed": "Okay, das schlage ich nicht noch einmal vor", + "stopped": "Verstanden – ich schlage keine Erinnerungen mehr vor", + "failed": "Hat nicht geklappt – bitte erneut versuchen", + "cadence": { + "weightDaily": "Wieg dich täglich, ungefähr zur selben Zeit", + "bp722": "Miss deinen Blutdruck eine Woche lang zweimal täglich", + "glucoseStructured": "Miss deine Glukose nach einem strukturierten Tagesplan" + } + }, + "settings": { + "reminderSuggestionsLabel": "Kadenz-Vorschläge", + "reminderSuggestionsDescription": "Lass den Coach gelegentlich eine evidenzbasierte Erinnerung anbieten, eine Metrik regelmäßiger zu messen." + } } } diff --git a/messages/en.json b/messages/en.json index 7b5bbdced..12c5fec23 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6619,5 +6619,26 @@ "label": "Medications", "description": "Schedules, intakes, and compliance." } + }, + "coach": { + "reminderSuggestion": { + "title": "Set a reminder?", + "accept": "Set reminder", + "dismiss": "Not now", + "stop": "I measure enough", + "accepted": "Reminder set", + "dismissed": "Okay, I won't suggest this again", + "stopped": "Got it — I'll stop suggesting reminders", + "failed": "Couldn't set that up — please try again", + "cadence": { + "weightDaily": "Weigh yourself daily, around the same time", + "bp722": "Measure your blood pressure twice a day for a week", + "glucoseStructured": "Test your glucose on a structured daily schedule" + } + }, + "settings": { + "reminderSuggestionsLabel": "Cadence suggestions", + "reminderSuggestionsDescription": "Let the Coach offer an occasional, evidence-based reminder to measure a metric more regularly." + } } } diff --git a/messages/es.json b/messages/es.json index 27d84b2a8..e169d82d4 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6619,5 +6619,26 @@ "label": "Medicamentos", "description": "Pautas, tomas y cumplimiento." } + }, + "coach": { + "reminderSuggestion": { + "title": "¿Crear un recordatorio?", + "accept": "Crear recordatorio", + "dismiss": "Ahora no", + "stop": "Ya mido suficiente", + "accepted": "Recordatorio creado", + "dismissed": "De acuerdo, no lo volveré a sugerir", + "stopped": "Entendido, dejaré de sugerir recordatorios", + "failed": "No se pudo crear, inténtalo de nuevo", + "cadence": { + "weightDaily": "Pésate a diario, más o menos a la misma hora", + "bp722": "Mide tu tensión dos veces al día durante una semana", + "glucoseStructured": "Mide tu glucosa con un horario diario estructurado" + } + }, + "settings": { + "reminderSuggestionsLabel": "Sugerencias de frecuencia", + "reminderSuggestionsDescription": "Permite que el Coach ofrezca de vez en cuando un recordatorio basado en evidencia para medir una métrica con más regularidad." + } } } diff --git a/messages/fr.json b/messages/fr.json index 25c877947..5d6540cdb 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6619,5 +6619,26 @@ "label": "Médicaments", "description": "Schémas, prises et observance." } + }, + "coach": { + "reminderSuggestion": { + "title": "Créer un rappel ?", + "accept": "Créer le rappel", + "dismiss": "Pas maintenant", + "stop": "Je mesure assez", + "accepted": "Rappel créé", + "dismissed": "D'accord, je ne le proposerai plus", + "stopped": "Compris, j'arrête de proposer des rappels", + "failed": "Échec de la création, veuillez réessayer", + "cadence": { + "weightDaily": "Pesez-vous chaque jour, à peu près à la même heure", + "bp722": "Mesurez votre tension deux fois par jour pendant une semaine", + "glucoseStructured": "Mesurez votre glycémie selon un horaire quotidien structuré" + } + }, + "settings": { + "reminderSuggestionsLabel": "Suggestions de fréquence", + "reminderSuggestionsDescription": "Laissez le Coach proposer de temps en temps un rappel fondé sur des preuves pour mesurer une donnée plus régulièrement." + } } } diff --git a/messages/it.json b/messages/it.json index 2f27bd97c..0bcfaeaf7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6619,5 +6619,26 @@ "label": "Farmaci", "description": "Schemi, assunzioni e aderenza." } + }, + "coach": { + "reminderSuggestion": { + "title": "Impostare un promemoria?", + "accept": "Imposta promemoria", + "dismiss": "Non ora", + "stop": "Misuro abbastanza", + "accepted": "Promemoria impostato", + "dismissed": "Va bene, non lo suggerirò più", + "stopped": "Capito, smetto di suggerire promemoria", + "failed": "Impossibile impostarlo, riprova", + "cadence": { + "weightDaily": "Pesati ogni giorno, più o meno alla stessa ora", + "bp722": "Misura la pressione due volte al giorno per una settimana", + "glucoseStructured": "Misura la glicemia con un programma giornaliero strutturato" + } + }, + "settings": { + "reminderSuggestionsLabel": "Suggerimenti di cadenza", + "reminderSuggestionsDescription": "Lascia che il Coach proponga ogni tanto un promemoria basato sull'evidenza per misurare una metrica più regolarmente." + } } } diff --git a/messages/pl.json b/messages/pl.json index 2a5354dbc..fe9c92e55 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6619,5 +6619,26 @@ "label": "Leki", "description": "Schematy, przyjęcia i zgodność." } + }, + "coach": { + "reminderSuggestion": { + "title": "Ustawić przypomnienie?", + "accept": "Ustaw przypomnienie", + "dismiss": "Nie teraz", + "stop": "Mierzę wystarczająco", + "accepted": "Przypomnienie ustawione", + "dismissed": "Dobrze, nie zaproponuję tego ponownie", + "stopped": "Rozumiem, przestanę proponować przypomnienia", + "failed": "Nie udało się ustawić, spróbuj ponownie", + "cadence": { + "weightDaily": "Waż się codziennie, mniej więcej o tej samej porze", + "bp722": "Mierz ciśnienie dwa razy dziennie przez tydzień", + "glucoseStructured": "Mierz glukozę według ustrukturyzowanego planu dziennego" + } + }, + "settings": { + "reminderSuggestionsLabel": "Sugestie częstotliwości", + "reminderSuggestionsDescription": "Pozwól Coachowi od czasu do czasu zaproponować oparte na dowodach przypomnienie, aby mierzyć daną wartość bardziej regularnie." + } } } diff --git a/src/app/api/coach/reminder-suggestions/route.ts b/src/app/api/coach/reminder-suggestions/route.ts new file mode 100644 index 000000000..d8647d27d --- /dev/null +++ b/src/app/api/coach/reminder-suggestions/route.ts @@ -0,0 +1,193 @@ +/** + * POST /api/coach/reminder-suggestions — act on a Coach cadence suggestion. + * + * v1.18.1 (Workstream C). The Coach proposes an evidence-based measurement + * cadence (the `---SUGGEST-REMINDER---` sentinel → `suggestion` SSE frame → + * action card). This endpoint is what the card's buttons call: + * + * - `accept` → create a `MeasurementReminder` with `origin: COACH`, + * routed through the SAME engine + endpoint semantics as the + * Vorsorge surface. The server resolves the cadence from the + * closed catalog (metric + interval/rrule + course window) — + * the client sends only the cadence id, never a schedule. + * - `dismiss` → dismissal memory (never re-suggest this cadence). + * - `stop` → the explicit "you measure enough — stop" path. + * + * Triple-dedup completion: the CREATE path here refuses a second live + * COACH reminder for the same metric, so a re-tap or a stale card cannot + * double-create even though the SUGGEST gate already deduped at emit time. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { + parseCoachPrefs, + DEFAULT_REMINDER_SUGGESTION_PREFS, +} from "@/lib/validations/coach-prefs"; +import { coachReminderSuggestionActionSchema } from "@/lib/validations/coach-reminder-suggestion"; +import { + CADENCE_CATALOG, + isCadenceId, +} from "@/lib/ai/coach/suggest-reminder"; +import { + computeReminderNextDueAt, + type ReminderScheduleInput, +} from "@/lib/measurement-reminders/scheduling"; +import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; + +const DEFAULT_TIMEZONE = "Europe/Berlin"; + +async function postAction(request: NextRequest): Promise { + const { user } = await requireAuth(); + // The Coach surface gates the suggestion card; mirror the gate here so a + // disabled-coach account cannot drive the endpoint directly. + const gate = await requireModuleEnabled(user.id, "coach"); + if (!gate.enabled) return gate.response; + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = coachReminderSuggestionActionSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + const { cadenceId, action } = parsed.data; + + if (!isCadenceId(cadenceId)) { + return apiError("coach.suggestion.unknownCadence", 422); + } + const cadence = CADENCE_CATALOG[cadenceId]; + + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { coachPrefsJson: true, timezone: true }, + }); + const coachPrefs = parseCoachPrefs(row?.coachPrefsJson); + const suggestionPrefs = + coachPrefs.reminderSuggestions ?? DEFAULT_REMINDER_SUGGESTION_PREFS; + + // ── dismiss / stop: prefs-only writes ──────────────────────── + if (action === "dismiss" || action === "stop") { + const next = { + ...suggestionPrefs, + ...(action === "stop" ? { stopped: true } : {}), + dismissedCadences: + action === "dismiss" && + !suggestionPrefs.dismissedCadences.includes(cadenceId) + ? [...suggestionPrefs.dismissedCadences, cadenceId].slice(0, 32) + : suggestionPrefs.dismissedCadences, + }; + await prisma.user.update({ + where: { id: user.id }, + data: { + coachPrefsJson: { ...coachPrefs, reminderSuggestions: next }, + }, + }); + annotate({ + action: { name: `coach.reminder.${action}` }, + meta: { cadenceId }, + }); + return apiSuccess({ ok: true, action }); + } + + // ── accept: create the reminder (origin: COACH) ────────────── + // Module-toggle re-check at create time (defence in depth — the gate + // already checked at emit, but a stale card could outlive a module flip). + if (cadence.module) { + const moduleGate = await requireModuleEnabled(user.id, cadence.module); + if (!moduleGate.enabled) return moduleGate.response; + } + + // Third dedup: refuse a second live COACH reminder for the same metric. + const existing = await prisma.measurementReminder.findFirst({ + where: { + userId: user.id, + origin: "COACH", + measurementType: cadence.measurementType, + deletedAt: null, + }, + select: { id: true }, + }); + if (existing) { + annotate({ + action: { name: "coach.reminder.accept.duplicate" }, + meta: { cadenceId, reminderId: existing.id }, + }); + return apiSuccess( + { ok: true, action: "accept", reminder: null, duplicate: true }, + 200, + ); + } + + const timezone = row?.timezone || DEFAULT_TIMEZONE; + const now = new Date(); + const endsOn = + cadence.courseDays != null + ? new Date(now.getTime() + cadence.courseDays * 24 * 60 * 60 * 1000) + : null; + + const scheduleInput: ReminderScheduleInput = { + intervalDays: cadence.intervalDays, + rrule: cadence.rrule, + anchorDate: null, + notifyHour: cadence.notifyHour, + lastSatisfiedAt: null, + createdAt: now, + endsOn, + }; + const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now); + + // Field-by-field — no mass assignment. `origin: COACH` is the + // provenance the UI labels and the SUGGEST gate dedupes against. + const created = await prisma.measurementReminder.create({ + data: { + userId: user.id, + label: cadence.labelKey, + measurementType: cadence.measurementType, + intervalDays: cadence.intervalDays, + rrule: cadence.rrule, + anchorDate: null, + endsOn, + origin: "COACH", + notifyHour: cadence.notifyHour, + location: null, + enabled: true, + nextDueAt, + }, + }); + + await auditLog("measurementReminder.create.coach", { + userId: user.id, + ipAddress: getClientIp(request), + details: { reminderId: created.id, cadenceId }, + }); + annotate({ + action: { name: "coach.reminder.accept" }, + meta: { cadenceId, reminderId: created.id }, + }); + + return apiSuccess( + { + ok: true, + action: "accept", + reminder: toMeasurementReminderDto(created), + duplicate: false, + }, + 201, + ); +} + +export const POST = apiHandler(postAction); +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index df06d2c80..812c38b80 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -69,7 +69,13 @@ import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt"; import { getSelfContextTextForUser } from "@/lib/ai/coach/about-me"; import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot"; import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues"; -import { parseCoachPrefs } from "@/lib/validations/coach-prefs"; +import { parseSuggestReminder } from "@/lib/ai/coach/suggest-reminder"; +import { gateSuggestion } from "@/lib/ai/coach/suggest-gate"; +import { + parseCoachPrefs, + DEFAULT_REMINDER_SUGGESTION_PREFS, +} from "@/lib/validations/coach-prefs"; +import type { CoachSuggestion } from "@/lib/ai/coach/types"; import { createSseStream } from "@/lib/sse/create-stream"; /** @@ -498,11 +504,66 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; }); return streamProviderError({ code: "coach.provider.empty" }); } - const replyText = proseAfterStrip; - const enrichedProvenance = - sentinel.keyValues.length > 0 - ? { ...snapshot.provenance, keyValues: sentinel.keyValues } - : snapshot.provenance; + // v1.18.1 (Workstream C) — strip the optional `---SUGGEST-REMINDER---` + // block out of the prose-after-keyvalues. The model proposes a cadence; + // the gate decides whether it actually surfaces (module-toggle + opt-out + // + dismissal memory + cooldown + dedup against a live COACH reminder). + // A suppressed proposal leaves the prose unchanged and emits no card. + const suggestParse = parseSuggestReminder(proseAfterStrip); + const replyText = suggestParse.prose.trim() || proseAfterStrip; + let surfacedSuggestion: CoachSuggestion | null = null; + if (suggestParse.cadence) { + const decision = await gateSuggestion({ + prisma, + userId, + cadence: suggestParse.cadence, + prefs: coachPrefs.reminderSuggestions ?? DEFAULT_REMINDER_SUGGESTION_PREFS, + }); + if (decision.surface) { + const cadence = suggestParse.cadence; + surfacedSuggestion = { + cadenceId: cadence.id, + measurementType: cadence.measurementType, + label: cadence.labelKey, + }; + // Stamp the cooldown anchor (frequency cap) onto the prefs blob. + const nextSuggestionPrefs = { + ...(coachPrefs.reminderSuggestions ?? + DEFAULT_REMINDER_SUGGESTION_PREFS), + lastSuggestedAt: new Date().toISOString(), + }; + void prisma.user + .update({ + where: { id: userId }, + data: { + coachPrefsJson: { + ...coachPrefs, + reminderSuggestions: nextSuggestionPrefs, + }, + }, + }) + .catch(() => { + // Cooldown stamp is best-effort: a write failure at worst lets a + // second suggestion through sooner, never breaks the chat turn. + }); + annotate({ + action: { name: "coach.reminder.suggested" }, + meta: { cadenceId: cadence.id, metric: cadence.measurementType }, + }); + } else { + annotate({ + action: { name: "coach.reminder.suppressed" }, + meta: { cadenceId: suggestParse.cadence.id, reason: decision.reason }, + }); + } + } + const enrichedProvenance: typeof snapshot.provenance = { + ...snapshot.provenance, + ...(sentinel.keyValues.length > 0 + ? { keyValues: sentinel.keyValues } + : {}), + ...(surfacedSuggestion ? { suggestion: surfacedSuggestion } : {}), + }; if (sentinel.malformed) { // Graceful degrade: log so ops can spot a provider whose // sentinel format has drifted, but pass the prose through @@ -585,6 +646,13 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; metricSource: enrichedProvenance, }), ); + // v1.18.1 (Workstream C) — additive `suggestion` frame. Older clients + // ignore it; newer ones render the one-tap action card. + if (surfacedSuggestion) { + controller.enqueue( + encodeFrame({ type: "suggestion", suggestion: surfacedSuggestion }), + ); + } controller.enqueue( encodeFrame({ type: "done", diff --git a/src/app/api/measurement-reminders/[id]/route.ts b/src/app/api/measurement-reminders/[id]/route.ts index 5813cca22..b163194a6 100644 --- a/src/app/api/measurement-reminders/[id]/route.ts +++ b/src/app/api/measurement-reminders/[id]/route.ts @@ -147,6 +147,9 @@ export const PATCH = apiHandler( (updateData.notifyHour as number | undefined) ?? existing.notifyHour, lastSatisfiedAt: existing.lastSatisfiedAt, createdAt: existing.createdAt, + // v1.18.1 — preserve the course window so an edit of a finite + // (Coach-suggested) cadence keeps self-expiring. + endsOn: existing.endsOn, }; const after = existing.lastSatisfiedAt && existing.lastSatisfiedAt > now diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index 81cda2edb..fc6cea16c 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -146,6 +146,7 @@ describe("", () => { streaming={{ content: "Looking at your data ", metricSource: null, + suggestion: null, inProgress: true, messageId: null, errorCode: null, @@ -170,6 +171,7 @@ describe("", () => { streaming={{ content: "Drafting…", metricSource: null, + suggestion: null, inProgress: true, messageId: null, errorCode: null, @@ -187,6 +189,7 @@ describe("", () => { streaming={{ content: "", metricSource: null, + suggestion: null, inProgress: true, messageId: null, errorCode: null, @@ -203,6 +206,7 @@ describe("", () => { streaming={{ content: "", metricSource: null, + suggestion: null, inProgress: false, messageId: null, errorCode: "errorProvider", @@ -243,6 +247,7 @@ describe("", () => { streaming={{ content: "Looking at your data ", metricSource: null, + suggestion: null, inProgress: false, messageId: "m3-streaming", errorCode: null, @@ -269,6 +274,7 @@ describe("", () => { streaming={{ content: "Looking at your data ", metricSource: null, + suggestion: null, inProgress: true, messageId: null, errorCode: null, @@ -467,6 +473,7 @@ describe("", () => { streaming={{ content: "", metricSource: null, + suggestion: null, inProgress: true, messageId: null, errorCode: null, @@ -570,6 +577,7 @@ describe("", () => { streaming={{ content: "", metricSource: null, + suggestion: null, inProgress: false, messageId: null, errorCode: "coach.budget.exceeded", @@ -590,6 +598,7 @@ describe("", () => { streaming={{ content: "", metricSource: null, + suggestion: null, inProgress: false, messageId: null, errorCode: "coach.provider.rate_limited", @@ -618,6 +627,7 @@ describe("", () => { streaming={{ content: "", metricSource: null, + suggestion: null, inProgress: false, messageId: null, errorCode: null, diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 2506b4784..59f0755e2 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -24,6 +24,7 @@ import { useAuth } from "@/hooks/use-auth"; import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me"; import { SourceChips } from "./source-chips"; +import { ReminderSuggestionCard } from "./reminder-suggestion-card"; import type { CoachConversationDetailDTO, CoachOptimisticUserMessage, @@ -406,6 +407,7 @@ export function MessageThread({ role="assistant" content={streaming.content} metricSource={streaming.metricSource} + suggestion={streaming.suggestion} providerType={streaming.inProgress ? "streaming" : null} inProgress={streaming.inProgress} errorCode={streaming.errorCode} @@ -425,6 +427,12 @@ interface ChatBubbleProps { role: "user" | "assistant"; content: string; metricSource?: import("@/lib/ai/coach/types").CoachProvenance | null; + /** + * v1.18.1 (Workstream C) — live cadence suggestion from the streaming + * hook. Persisted messages carry it on `metricSource.suggestion` + * instead; the bubble falls back to that so the card survives reload. + */ + suggestion?: import("@/lib/ai/coach/types").CoachSuggestion | null; providerType?: string | null; inProgress?: boolean; errorCode?: string | null; @@ -440,6 +448,7 @@ function ChatBubble({ role, content, metricSource, + suggestion, providerType, inProgress, errorCode, @@ -699,6 +708,16 @@ function ChatBubble({ )} + {/* v1.18.1 (Workstream C) — one-tap cadence-suggestion action + card. Live from the streaming hook, or restored from the + persisted message provenance on reload. Not shown on + in-flight or errored turns. */} + {!inProgress && + !errorCode && + (() => { + const sug = suggestion ?? metricSource?.suggestion ?? null; + return sug ? : null; + })()} {/* v1.4.23 H7 — per-message thumbs feedback. Only persisted assistant messages get the row (skipped for refusals, errors, in-flight stream bubbles). The aggregator buckets diff --git a/src/components/insights/coach-panel/reminder-suggestion-card.tsx b/src/components/insights/coach-panel/reminder-suggestion-card.tsx new file mode 100644 index 000000000..517eff767 --- /dev/null +++ b/src/components/insights/coach-panel/reminder-suggestion-card.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { BellPlus, Check, Loader2 } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { useTranslations } from "@/lib/i18n/context"; +import { apiPost } from "@/lib/api/api-fetch"; +import type { CoachSuggestion } from "@/lib/ai/coach/types"; + +/** + * v1.18.1 (Workstream C) — the one-tap cadence-suggestion action card. + * + * Rendered under an assistant bubble when the turn carried a `suggestion` + * (live from the SSE frame, or persisted on the message provenance). Three + * actions, all routed through `POST /api/coach/reminder-suggestions`: + * + * - "Set reminder" → creates a `MeasurementReminder` (origin: COACH). + * - "Not now" → dismissal memory (never re-suggest this cadence). + * - "I measure enough" → the explicit stop path (no more suggestions). + * + * RENDER dedup (the middle leg of the triple dedup): once the user settles + * the card it collapses to a confirmation and never re-renders an action, + * so a stale stream + persisted twin can't double-prompt. + */ +export function ReminderSuggestionCard({ + suggestion, +}: { + suggestion: CoachSuggestion; +}) { + const { t } = useTranslations(); + const [settled, setSettled] = useState< + "accepted" | "dismissed" | "stopped" | null + >(null); + + const act = useMutation({ + mutationFn: async (action: "accept" | "dismiss" | "stop") => { + await apiPost("/api/coach/reminder-suggestions", { + cadenceId: suggestion.cadenceId, + action, + }); + return action; + }, + onSuccess: (action) => { + setSettled( + action === "accept" + ? "accepted" + : action === "stop" + ? "stopped" + : "dismissed", + ); + }, + onError: () => { + toast.error(t("coach.reminderSuggestion.failed")); + }, + }); + + if (settled) { + return ( +

+

+ ); + } + + const busy = act.isPending; + + return ( +
+
+ +
+ + {t("coach.reminderSuggestion.title")} + + {/* The label is a localised cadence string (the i18n key the + server hands back in `suggestion.label`). */} + + {t(suggestion.label)} + +
+
+
+ + + +
+
+ ); +} diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index 45d4abf2b..3aa7058c9 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -10,6 +10,7 @@ import type { CoachProvenance, CoachScope, CoachStreamEvent, + CoachSuggestion, } from "@/lib/ai/coach/types"; import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; @@ -178,6 +179,11 @@ export interface CoachStreamingMessage { content: string; /** Provenance frame — populated once the server emits it. */ metricSource: CoachProvenance | null; + /** + * v1.18.1 (Workstream C) — cadence suggestion from the additive + * `suggestion` frame, if the turn carried one. Null otherwise. + */ + suggestion: CoachSuggestion | null; /** True until the `done` frame closes the stream. */ inProgress: boolean; /** Final messageId once `done` lands; null otherwise. */ @@ -220,6 +226,7 @@ export interface CoachOptimisticUserMessage { const EMPTY_STREAMING: CoachStreamingMessage = { content: "", metricSource: null, + suggestion: null, inProgress: false, messageId: null, errorCode: null, @@ -314,6 +321,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { setStreaming({ content: "", metricSource: null, + suggestion: null, inProgress: true, messageId: null, errorCode: null, @@ -326,6 +334,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { setStreaming({ content: "", metricSource: null, + suggestion: null, inProgress: false, messageId: null, errorCode: "coach.network", @@ -358,6 +367,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { setStreaming({ content: "", metricSource: null, + suggestion: null, inProgress: false, messageId: null, errorCode: "coach.network", @@ -369,6 +379,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { setStreaming({ content: "", metricSource: null, + suggestion: null, inProgress: false, messageId: null, errorCode: `coach.http.${response.status}`, @@ -405,6 +416,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { setStreaming({ content: "", metricSource: null, + suggestion: null, inProgress: false, messageId: null, errorCode: structured ?? `coach.http.${response.status}`, @@ -423,6 +435,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { let resolvedConversationId: string | null = null; let collectedContent = ""; let collectedProvenance: CoachProvenance | null = null; + let collectedSuggestion: CoachSuggestion | null = null; let lastError: string | null = null; let messageId: string | null = null; @@ -449,6 +462,13 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { metricSource: evt.metricSource, })); break; + case "suggestion": + collectedSuggestion = evt.suggestion; + setStreaming((prev) => ({ + ...prev, + suggestion: evt.suggestion, + })); + break; case "done": resolvedConversationId = evt.conversationId; messageId = evt.messageId; @@ -478,6 +498,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) { setStreaming({ content: collectedContent, metricSource: collectedProvenance, + suggestion: collectedSuggestion, inProgress: false, messageId, errorCode: lastError, @@ -529,4 +550,5 @@ export type { CoachConversationDTO, CoachConversationDetailDTO, CoachProvenance, + CoachSuggestion, }; diff --git a/src/components/settings/coach-prefs-section.tsx b/src/components/settings/coach-prefs-section.tsx index be712b335..22b580069 100644 --- a/src/components/settings/coach-prefs-section.tsx +++ b/src/components/settings/coach-prefs-section.tsx @@ -296,6 +296,42 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) { })} + + {/* v1.18.1 (Workstream C) — cadence-suggestion opt-out. The + Coach offers an occasional, evidence-based reminder to + measure a metric more regularly; this is the master switch + for that surface. Off ⇒ no suggestion cards ever surface. */} +
+
+ +

+ {t("coach.settings.reminderSuggestionsDescription")} +

+
+ + setDraft((prev) => ({ + ...prev, + reminderSuggestions: { + enabled: next, + stopped: prev.reminderSuggestions?.stopped ?? false, + dismissedCadences: + prev.reminderSuggestions?.dismissedCadences ?? [], + lastSuggestedAt: + prev.reminderSuggestions?.lastSuggestedAt ?? null, + }, + })) + } + /> +
)}
diff --git a/src/lib/ai/coach/__tests__/suggest-gate.test.ts b/src/lib/ai/coach/__tests__/suggest-gate.test.ts new file mode 100644 index 000000000..038c95dc1 --- /dev/null +++ b/src/lib/ai/coach/__tests__/suggest-gate.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { + decideFromPrefs, + gateSuggestion, + SUGGESTION_COOLDOWN_DAYS, +} from "@/lib/ai/coach/suggest-gate"; +import { CADENCE_CATALOG } from "@/lib/ai/coach/suggest-reminder"; +import { DEFAULT_REMINDER_SUGGESTION_PREFS } from "@/lib/validations/coach-prefs"; + +vi.mock("@/lib/modules/gate", () => ({ + isModuleEnabled: vi.fn(async () => true), +})); +import { isModuleEnabled } from "@/lib/modules/gate"; + +const NOW = new Date("2026-06-16T12:00:00.000Z"); + +describe("decideFromPrefs", () => { + it("surfaces with default prefs", () => { + expect( + decideFromPrefs(DEFAULT_REMINDER_SUGGESTION_PREFS, "weight_daily", NOW), + ).toEqual({ surface: true }); + }); + + it("suppresses when disabled", () => { + const d = decideFromPrefs( + { ...DEFAULT_REMINDER_SUGGESTION_PREFS, enabled: false }, + "weight_daily", + NOW, + ); + expect(d).toEqual({ surface: false, reason: "disabled" }); + }); + + it("suppresses when the user said stop", () => { + const d = decideFromPrefs( + { ...DEFAULT_REMINDER_SUGGESTION_PREFS, stopped: true }, + "weight_daily", + NOW, + ); + expect(d).toEqual({ surface: false, reason: "stopped" }); + }); + + it("suppresses a dismissed cadence (dismissal memory)", () => { + const d = decideFromPrefs( + { ...DEFAULT_REMINDER_SUGGESTION_PREFS, dismissedCadences: ["weight_daily"] }, + "weight_daily", + NOW, + ); + expect(d).toEqual({ surface: false, reason: "dismissed" }); + }); + + it("suppresses inside the cooldown window and surfaces after it", () => { + const justNow = new Date(NOW.getTime() - 60_000).toISOString(); + expect( + decideFromPrefs( + { ...DEFAULT_REMINDER_SUGGESTION_PREFS, lastSuggestedAt: justNow }, + "weight_daily", + NOW, + ), + ).toEqual({ surface: false, reason: "cooldown" }); + + const longAgo = new Date( + NOW.getTime() - (SUGGESTION_COOLDOWN_DAYS + 1) * 24 * 60 * 60 * 1000, + ).toISOString(); + expect( + decideFromPrefs( + { ...DEFAULT_REMINDER_SUGGESTION_PREFS, lastSuggestedAt: longAgo }, + "weight_daily", + NOW, + ), + ).toEqual({ surface: true }); + }); +}); + +describe("gateSuggestion", () => { + beforeEach(() => { + vi.mocked(isModuleEnabled).mockReset(); + vi.mocked(isModuleEnabled).mockResolvedValue(true); + }); + + function fakePrisma(existing: { id: string } | null) { + return { + measurementReminder: { + findFirst: vi.fn(async () => existing), + }, + } as never; + } + + it("surfaces a core-domain cadence with no live duplicate", async () => { + const prisma = fakePrisma(null); + const d = await gateSuggestion({ + prisma, + userId: "u1", + cadence: CADENCE_CATALOG.weight_daily, + now: NOW, + }); + expect(d).toEqual({ surface: true }); + }); + + it("dedups against a live COACH reminder for the metric", async () => { + const prisma = fakePrisma({ id: "r1" }); + const d = await gateSuggestion({ + prisma, + userId: "u1", + cadence: CADENCE_CATALOG.weight_daily, + now: NOW, + }); + expect(d).toEqual({ surface: false, reason: "duplicate" }); + }); + + it("suppresses a module-gated cadence when its module is disabled", async () => { + vi.mocked(isModuleEnabled).mockResolvedValue(false); + const prisma = fakePrisma(null); + const d = await gateSuggestion({ + prisma, + userId: "u1", + cadence: CADENCE_CATALOG.glucose_structured, + now: NOW, + }); + expect(d).toEqual({ surface: false, reason: "module_disabled" }); + expect(isModuleEnabled).toHaveBeenCalledWith("u1", "glucose"); + }); + + it("never queries the DB when the pref decision already suppresses", async () => { + const prisma = fakePrisma(null); + await gateSuggestion({ + prisma, + userId: "u1", + cadence: CADENCE_CATALOG.weight_daily, + prefs: { ...DEFAULT_REMINDER_SUGGESTION_PREFS, stopped: true }, + now: NOW, + }); + expect( + (prisma as unknown as { measurementReminder: { findFirst: ReturnType } }) + .measurementReminder.findFirst, + ).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/ai/coach/__tests__/suggest-reminder.test.ts b/src/lib/ai/coach/__tests__/suggest-reminder.test.ts new file mode 100644 index 000000000..d8d3d173e --- /dev/null +++ b/src/lib/ai/coach/__tests__/suggest-reminder.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; + +import { + parseSuggestReminder, + isCadenceId, + CADENCE_CATALOG, +} from "@/lib/ai/coach/suggest-reminder"; + +describe("parseSuggestReminder", () => { + it("returns the prose unchanged when no sentinel is present", () => { + const r = parseSuggestReminder("Just a normal reply."); + expect(r.prose).toBe("Just a normal reply."); + expect(r.cadence).toBeNull(); + expect(r.malformed).toBe(false); + }); + + it("strips the block and resolves a valid cadence id", () => { + const raw = + "Maybe take a proper week of readings.\n" + + "---SUGGEST-REMINDER---\n" + + "cadence: bp_7_2_2\n" + + "---END---"; + const r = parseSuggestReminder(raw); + expect(r.prose).toBe("Maybe take a proper week of readings."); + expect(r.cadence?.id).toBe("bp_7_2_2"); + expect(r.malformed).toBe(false); + }); + + it("strips quotes/backticks the model may add around the id", () => { + const raw = + "text\n---SUGGEST-REMINDER---\ncadence: `weight_daily`\n---END---"; + expect(parseSuggestReminder(raw).cadence?.id).toBe("weight_daily"); + }); + + it("ignores informational reason lines", () => { + const raw = + "text\n---SUGGEST-REMINDER---\nreason: bp erratic\ncadence: bp_7_2_2\n---END---"; + expect(parseSuggestReminder(raw).cadence?.id).toBe("bp_7_2_2"); + }); + + it("flags malformed and strips the block on an unknown cadence id", () => { + const raw = + "text\n---SUGGEST-REMINDER---\ncadence: bp_hourly\n---END---"; + const r = parseSuggestReminder(raw); + expect(r.cadence).toBeNull(); + expect(r.malformed).toBe(true); + // The raw marker must never leak into the prose shown to the user. + expect(r.prose).not.toContain("SUGGEST-REMINDER"); + expect(r.prose).toBe("text"); + }); + + it("tolerates a missing close marker (caps + still parses)", () => { + const raw = "text\n---SUGGEST-REMINDER---\ncadence: weight_daily\n"; + expect(parseSuggestReminder(raw).cadence?.id).toBe("weight_daily"); + }); + + it("caps an oversized payload before parsing", () => { + const raw = + "text\n---SUGGEST-REMINDER---\n" + + "x".repeat(2000) + + "\ncadence: weight_daily\n---END---"; + // The cadence line sits past the 512-byte cap → not found → malformed. + const r = parseSuggestReminder(raw); + expect(r.cadence).toBeNull(); + expect(r.malformed).toBe(true); + }); +}); + +describe("CADENCE_CATALOG", () => { + it("isCadenceId only accepts catalog tokens", () => { + expect(isCadenceId("bp_7_2_2")).toBe(true); + expect(isCadenceId("weight_daily")).toBe(true); + expect(isCadenceId("hrv_daily")).toBe(false); + expect(isCadenceId("__proto__")).toBe(false); + }); + + it("every preset sets exactly one of intervalDays / rrule", () => { + for (const c of Object.values(CADENCE_CATALOG)) { + const hasInterval = c.intervalDays != null; + const hasRrule = c.rrule != null; + expect(hasInterval !== hasRrule).toBe(true); + } + }); + + it("the BP protocol is finite (course-windowed) and the others open-ended", () => { + expect(CADENCE_CATALOG.bp_7_2_2.courseDays).toBe(7); + expect(CADENCE_CATALOG.weight_daily.courseDays).toBeNull(); + expect(CADENCE_CATALOG.glucose_structured.courseDays).toBeNull(); + }); + + it("does not include passive RHR/HRV cadences", () => { + const types = Object.values(CADENCE_CATALOG).map((c) => c.measurementType); + expect(types).not.toContain("RESTING_HEART_RATE"); + expect(types).not.toContain("HEART_RATE_VARIABILITY"); + }); +}); diff --git a/src/lib/ai/coach/suggest-gate.ts b/src/lib/ai/coach/suggest-gate.ts new file mode 100644 index 000000000..6334ed2f8 --- /dev/null +++ b/src/lib/ai/coach/suggest-gate.ts @@ -0,0 +1,116 @@ +/** + * v1.18.1 (Workstream C) — the non-naggy gate for Coach cadence + * suggestions. + * + * The model can PROPOSE a cadence (the `---SUGGEST-REMINDER---` sentinel); + * this module is the SERVER's decision on whether that proposal actually + * surfaces as an action card. It is the first of the TRIPLE dedup: + * + * 1. SUGGEST gate (here): module-toggle + opt-out/stop + dismissal + * memory + cooldown + dedup against a live COACH reminder for the + * metric. A proposal that fails any of these is dropped silently — no + * `suggestion` frame. + * 2. RENDER (client): the card renders once per assistant message. + * 3. CREATE (`POST /api/measurement-reminders`): the create path refuses + * a second live COACH reminder for the same metric (a re-tap or a + * stale card cannot double-create). + * + * Pure decision functions + one DB-reading orchestrator so the gate logic + * is unit-testable without a database. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import type { CadencePreset } from "./suggest-reminder"; +import type { CoachReminderSuggestionPrefs } from "@/lib/validations/coach-prefs"; +import { DEFAULT_REMINDER_SUGGESTION_PREFS } from "@/lib/validations/coach-prefs"; +import { isModuleEnabled } from "@/lib/modules/gate"; + +/** + * Cooldown between two cadence suggestions. A frequency cap, not a + * per-cadence cap: once the Coach suggested anything, it stays quiet for + * this window regardless of which cadence comes up next, so a chatty + * session never stacks cards. + */ +export const SUGGESTION_COOLDOWN_DAYS = 14; +const SUGGESTION_COOLDOWN_MS = SUGGESTION_COOLDOWN_DAYS * 24 * 60 * 60 * 1000; + +/** Why a proposal was suppressed (annotation meta; never user-facing). */ +export type SuggestionSuppressReason = + | "disabled" + | "stopped" + | "dismissed" + | "cooldown" + | "module_disabled" + | "duplicate"; + +export type SuggestionDecision = + | { surface: true } + | { surface: false; reason: SuggestionSuppressReason }; + +/** + * Pure pref-level decision (no DB). Applies opt-out, the explicit stop + * path, dismissal memory, and the cooldown. The caller layers the + * module-toggle + dedup checks (which need a DB) on top. + */ +export function decideFromPrefs( + prefs: CoachReminderSuggestionPrefs, + cadenceId: string, + now: Date, +): SuggestionDecision { + if (!prefs.enabled) return { surface: false, reason: "disabled" }; + if (prefs.stopped) return { surface: false, reason: "stopped" }; + if (prefs.dismissedCadences.includes(cadenceId)) { + return { surface: false, reason: "dismissed" }; + } + if (prefs.lastSuggestedAt) { + const last = new Date(prefs.lastSuggestedAt).getTime(); + if ( + Number.isFinite(last) && + now.getTime() - last < SUGGESTION_COOLDOWN_MS + ) { + return { surface: false, reason: "cooldown" }; + } + } + return { surface: true }; +} + +/** + * Full gate: pref decision, then the module-toggle, then dedup against a + * live COACH reminder for the cadence's metric. Returns the surface + * decision; the route emits the `suggestion` frame only when + * `surface === true` and stamps `lastSuggestedAt` so the cooldown starts. + */ +export async function gateSuggestion(args: { + prisma: PrismaClient; + userId: string; + cadence: CadencePreset; + prefs?: CoachReminderSuggestionPrefs; + now?: Date; +}): Promise { + const prefs = args.prefs ?? DEFAULT_REMINDER_SUGGESTION_PREFS; + const now = args.now ?? new Date(); + + const prefDecision = decideFromPrefs(prefs, args.cadence.id, now); + if (!prefDecision.surface) return prefDecision; + + // Module-toggle: a cadence for a disabled module never surfaces. Core + // domains (`module: null`) are always available. + if (args.cadence.module) { + const enabled = await isModuleEnabled(args.userId, args.cadence.module); + if (!enabled) return { surface: false, reason: "module_disabled" }; + } + + // Dedup: a live (non-deleted) COACH reminder for the same metric means + // the user already accepted this cadence — never suggest it again. + const existing = await args.prisma.measurementReminder.findFirst({ + where: { + userId: args.userId, + origin: "COACH", + measurementType: args.cadence.measurementType, + deletedAt: null, + }, + select: { id: true }, + }); + if (existing) return { surface: false, reason: "duplicate" }; + + return { surface: true }; +} diff --git a/src/lib/ai/coach/suggest-reminder.ts b/src/lib/ai/coach/suggest-reminder.ts new file mode 100644 index 000000000..4eb4df5bc --- /dev/null +++ b/src/lib/ai/coach/suggest-reminder.ts @@ -0,0 +1,188 @@ +/** + * v1.18.1 (Workstream C) — Coach cadence suggestions. + * + * The Coach can append a `---SUGGEST-REMINDER---` … `---END---` sentinel + * block to its reply when an evidence-based measurement cadence is worth + * proposing (a new/changed med, erratic readings, a fast trend, a target + * off-track, or a measurement gap). The route strips the block out of the + * prose — exactly like the `---KEYVALUES---` evidence block — parses one + * cadence id out of it, validates it against the closed CADENCE_CATALOG, + * and emits an ADDITIVE `suggestion` SSE frame the client renders as a + * one-tap action card. Accepting the card POSTs to + * `POST /api/measurement-reminders` with `origin: COACH`; the SERVER + * resolves the actual cadence (intervalDays / rrule / endsOn) — the model + * only names the metric + the preset. + * + * Why a closed catalog and not free-form cadence fields from the model: + * - Evidence-based cadences ONLY. The model picks from a fixed set + * grounded in ESH/AHA + general consistency guidance; it cannot mint a + * "measure your BP 6×/day" nag. + * - Server-authoritative scheduling. The reminder rows are computed by + * the same recurrence engine the Vorsorge surface uses; web ↔ iOS read + * identical numbers. + * + * Defence-in-depth mirrors `keyvalues.ts`: 512-byte cap, one cadence id + * kept, validated against the catalog, malformed → dropped (no card). + */ +import type { MeasurementReminderType } from "@/lib/validations/measurement-reminders"; + +const OPEN_SENTINEL = "---SUGGEST-REMINDER---"; +const CLOSE_SENTINEL = "---END---"; +/** Hard cap on the sentinel payload before parsing (prompt-injection guard). */ +const SENTINEL_BYTE_CAP = 512; + +/** + * A cadence the Coach may propose. `id` is the stable token the model + * emits; everything else is server-owned so the model cannot widen a + * cadence or invent a metric. + * + * `measurementType` is the auto-resolve target the reminder row carries + * (the eventful engine advances `lastSatisfiedAt` when a matching reading + * lands). `module` is the module-gate key that must be enabled for the + * suggestion to surface — disabled module ⇒ no suggestion. + * + * Exactly one of `intervalDays` / `rrule` is set, matching the reminder + * create contract. `courseDays`, when set, bounds a finite course window + * (`endsOn = now + courseDays`) for cadences that are deliberately + * time-boxed (the ESH/AHA 7-day BP protocol). + */ +export interface CadencePreset { + id: string; + measurementType: MeasurementReminderType; + /** Module-gate key; null ⇒ a core domain (never gated). */ + module: "glucose" | null; + intervalDays: number | null; + rrule: string | null; + /** Finite course window in days; null ⇒ open-ended cadence. */ + courseDays: number | null; + /** i18n key for the action-card label ("Measure your BP twice daily…"). */ + labelKey: string; + /** Notify hour default (0–23). Mirrors the reminder create default. */ + notifyHour: number; +} + +/** + * The closed catalog. Evidence-based cadences only: + * - weight: daily consistency (a stable cadence beats sporadic spikes). + * - bp_7_2_2: ESH/AHA home-BP protocol — twice daily, morning + evening, + * for 7 days. Time-boxed (courseDays:7) so it self-expires. + * - glucose_structured: structured testing on a daily cadence (only when + * the glucose module is enabled). + * + * Deliberately NOT here: RHR / HRV (passive nightly samples — the Coach + * surfaces the baseline, it does not nudge "go measure" them). See the + * Workstream-C plan. + */ +export const CADENCE_CATALOG: Readonly> = + Object.freeze({ + weight_daily: { + id: "weight_daily", + measurementType: "WEIGHT", + module: null, + intervalDays: 1, + rrule: null, + courseDays: null, + labelKey: "coach.reminderSuggestion.cadence.weightDaily", + notifyHour: 8, + }, + bp_7_2_2: { + id: "bp_7_2_2", + measurementType: "BLOOD_PRESSURE_SYS", + module: null, + // Twice daily, morning + evening, on a finite 7-day course (ESH/AHA). + intervalDays: null, + rrule: "FREQ=DAILY;BYHOUR=7,19;INTERVAL=1", + courseDays: 7, + labelKey: "coach.reminderSuggestion.cadence.bp722", + notifyHour: 7, + }, + glucose_structured: { + id: "glucose_structured", + measurementType: "BLOOD_GLUCOSE", + module: "glucose", + intervalDays: 1, + rrule: null, + courseDays: null, + labelKey: "coach.reminderSuggestion.cadence.glucoseStructured", + notifyHour: 8, + }, + }); + +export type CadenceId = keyof typeof CADENCE_CATALOG; + +/** True for a string the model emitted that names a real catalog cadence. */ +export function isCadenceId(value: string): value is CadenceId { + return Object.prototype.hasOwnProperty.call(CADENCE_CATALOG, value); +} + +/** + * Result of stripping the `---SUGGEST-REMINDER---` block out of a reply. + * + * `prose` is the reply with the block removed (the KEYVALUES strip runs + * first; this strips what remains). `cadence` is the resolved preset when + * the model named a valid, in-catalog cadence; `null` otherwise (no block, + * malformed block, or an unknown cadence id — all degrade to "no card"). + */ +export interface SuggestReminderParseResult { + prose: string; + cadence: CadencePreset | null; + /** True when an opening sentinel was seen but yielded no valid cadence. */ + malformed: boolean; +} + +/** + * Parse one body line of the sentinel block. The contract is a single + * `cadence: ` line (extra lines are ignored). We tolerate the model + * emitting a `reason:` line too — it is informational and dropped. + */ +function extractCadenceId(body: string): string | null { + for (const rawLine of body.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + const colon = line.indexOf(":"); + if (colon < 1) continue; + const key = line.slice(0, colon).trim().toLowerCase(); + if (key !== "cadence") continue; + const value = line + .slice(colon + 1) + .trim() + // Strip surrounding quotes / backticks the model sometimes adds. + .replace(/^["'`]|["'`]$/g, ""); + if (value) return value; + } + return null; +} + +/** + * Split a reply into `{ prose, cadence }`, stripping the + * `---SUGGEST-REMINDER---` block. Run this AFTER `parseKeyValuesSentinel` + * so the prose it receives is already free of the evidence block. + */ +export function parseSuggestReminder(raw: string): SuggestReminderParseResult { + if (!raw) return { prose: "", cadence: null, malformed: false }; + + const openIdx = raw.indexOf(OPEN_SENTINEL); + if (openIdx === -1) { + return { prose: raw, cadence: null, malformed: false }; + } + + const prose = raw.slice(0, openIdx).replace(/\s+$/u, ""); + const afterOpen = raw.slice(openIdx + OPEN_SENTINEL.length); + const closeRel = afterOpen.indexOf(CLOSE_SENTINEL); + // Cap the payload before parsing regardless of whether the close marker + // is present (a missing close marker treats the rest as the body). + const bodyRaw = + closeRel === -1 ? afterOpen : afterOpen.slice(0, closeRel); + const body = + bodyRaw.length > SENTINEL_BYTE_CAP + ? bodyRaw.slice(0, SENTINEL_BYTE_CAP) + : bodyRaw; + + const cadenceId = extractCadenceId(body); + if (cadenceId && isCadenceId(cadenceId)) { + return { prose, cadence: CADENCE_CATALOG[cadenceId], malformed: false }; + } + // Opening sentinel present but no valid cadence — strip the block from + // the prose (the user must never see the raw marker) and flag malformed. + return { prose, cadence: null, malformed: true }; +} diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 5ac0874bf..608aaf262 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -307,6 +307,42 @@ Rules: a collapsible "What I'm looking at" disclosure under your message, so don't reference it in your prose. +SUGGEST A MEASUREMENT CADENCE (optional) + +When — and ONLY when — a real state change makes a regular measurement +cadence genuinely worth it, you may append ONE cadence-suggestion block +AFTER the evidence block. A "real state change" is: the user mentions a +new or changed medication, the readings you can see are erratic, a trend +is moving fast, a target is off-track, or there is a clear data gap (the +user stopped logging a metric they used to track). Never suggest a cadence +just because the conversation is going well, and never suggest more than +one per reply. + +Format exactly (the user never sees this block — the app renders it as a +one-tap "set a reminder" card): + +---SUGGEST-REMINDER--- +cadence: +---END--- + + is EXACTLY one of these evidence-based cadences — never invent one, +never change its frequency: +- weight_daily — daily weight, for consistency (weigh at the same time). +- bp_7_2_2 — the ESH/AHA home-BP protocol: twice daily (morning + evening) + for 7 days. Suggest this when blood pressure is the topic and a proper + reading series would help. +- glucose_structured — structured daily glucose testing. Only when glucose + is the topic. + +Rules: +- Mention the idea naturally in your prose ("it might help to take a proper + week of readings"), then append the block. Do NOT describe the block + itself or name the id in your prose. +- NEVER suggest a cadence for resting heart rate or HRV — those are passive + nightly readings, not something the user goes and measures. +- Omit the block entirely on any turn without a real state change. Silence + is the default; a suggestion is the exception. + EXAMPLES — TONE CALIBRATION @@ -619,6 +655,42 @@ Regeln: "Worauf bezieht sich das?"-Box unter deiner Nachricht. Verweise im Fließtext nicht auf den Block. +EINE MESS-KADENZ VORSCHLAGEN (optional) + +Wenn — und NUR wenn — eine echte Veränderung eine regelmäßige Mess-Kadenz +wirklich sinnvoll macht, darfst du EINEN Kadenz-Vorschlagsblock NACH dem +Evidenz-Block anhängen. Eine "echte Veränderung" ist: der Nutzer nennt ein +neues oder geändertes Medikament, die sichtbaren Werte sind unregelmäßig, +ein Trend bewegt sich schnell, ein Zielwert ist aus dem Ruder, oder es gibt +eine klare Datenlücke (der Nutzer hat aufgehört, eine zuvor getrackte +Metrik zu erfassen). Schlage nie eine Kadenz vor, nur weil das Gespräch gut +läuft, und nie mehr als eine pro Antwort. + +Format genau (der Nutzer sieht diesen Block nicht — die App rendert ihn als +"Erinnerung einrichten"-Karte mit einem Tap): + +---SUGGEST-REMINDER--- +cadence: +---END--- + + ist GENAU eine dieser evidenzbasierten Kadenzen — erfinde nie eine, +ändere nie die Frequenz: +- weight_daily — tägliches Gewicht, für Konstanz (zur selben Zeit wiegen). +- bp_7_2_2 — das ESH/AHA-Heim-Blutdruck-Protokoll: zweimal täglich (morgens + + abends) über 7 Tage. Schlage es vor, wenn Blutdruck das Thema ist und + eine ordentliche Messreihe helfen würde. +- glucose_structured — strukturiertes tägliches Glukose-Testen. Nur wenn + Glukose das Thema ist. + +Regeln: +- Erwähne die Idee natürlich im Fließtext ("es könnte helfen, eine richtige + Woche zu messen"), dann häng den Block an. Beschreibe den Block NICHT und + nenne die id NICHT im Fließtext. +- Schlage NIE eine Kadenz für Ruhepuls oder HRV vor — das sind passive + nächtliche Werte, nichts, das der Nutzer aktiv misst. +- Lass den Block auf jeder Antwort ohne echte Veränderung komplett weg. + Stille ist die Voreinstellung; ein Vorschlag ist die Ausnahme. + BEISPIELE — TONFALL diff --git a/src/lib/ai/coach/types.ts b/src/lib/ai/coach/types.ts index 5e7a88cb7..7d50c42ae 100644 --- a/src/lib/ai/coach/types.ts +++ b/src/lib/ai/coach/types.ts @@ -144,11 +144,35 @@ export const coachChatRequestSchema = z.object({ guidedQuestion: z.string().min(1).max(500).optional(), }); +/** + * v1.18.1 (Workstream C) — a Coach cadence suggestion surfaced as a + * one-tap action card. The model proposes the cadence via a sentinel + * block; the server resolves it against the closed cadence catalog and + * gates it (module-toggle + opt-out + dismissal + cooldown + dedup). When + * it surfaces, this DTO rides an additive `suggestion` SSE frame AND is + * persisted onto the assistant message's provenance so the card survives a + * conversation reload. + * + * `cadenceId` is the catalog token; `measurementType` the auto-resolve + * target; `label` the localised card copy. Accepting the card POSTs to + * `POST /api/measurement-reminders` with `origin: COACH` + the cadence's + * server-resolved schedule — the client sends only `cadenceId`, the server + * looks up the rest, so the client can never widen a cadence. + */ +export interface CoachSuggestion { + cadenceId: string; + measurementType: string; + label: string; +} + /** * SSE event shapes emitted by the streaming endpoint. * * The route writes one `data: \n\n` frame per event. Clients * dispatch on `type` and ignore unknown variants — additive evolution. + * The `suggestion` frame (v1.18.1) is additive: older web + iOS clients + * that don't know it drop it on the floor (the parser keeps only frames + * whose `type` it handles), so the chat stays backwards-compatible. */ export type CoachStreamEvent = | { type: "token"; token: string } @@ -156,6 +180,7 @@ export type CoachStreamEvent = type: "provenance"; metricSource: CoachProvenance; } + | { type: "suggestion"; suggestion: CoachSuggestion } | { type: "done"; conversationId: string; messageId: string } | { type: "error"; code: string; message: string }; @@ -277,6 +302,12 @@ export interface CoachProvenance { * snapshot was empty. Hard cap 8 entries to keep the block scannable. */ keyValues?: ReadonlyArray; + /** + * v1.18.1 (Workstream C) — a cadence suggestion attached to this turn. + * Persisted alongside the message so the one-tap action card re-renders + * on a conversation reload. Absent on turns that carry no suggestion. + */ + suggestion?: CoachSuggestion; } /** diff --git a/src/lib/measurement-reminders/__tests__/dto.test.ts b/src/lib/measurement-reminders/__tests__/dto.test.ts new file mode 100644 index 000000000..59faf3f0b --- /dev/null +++ b/src/lib/measurement-reminders/__tests__/dto.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; + +import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; +import type { MeasurementReminder } from "@/generated/prisma/client"; + +function row(overrides: Partial = {}): MeasurementReminder { + const now = new Date("2026-06-16T08:00:00.000Z"); + return { + id: "r1", + userId: "u1", + label: "Measure your blood pressure twice a day for a week", + measurementType: "BLOOD_PRESSURE_SYS", + intervalDays: null, + rrule: "FREQ=DAILY;BYHOUR=7,19;INTERVAL=1", + anchorDate: null, + endsOn: new Date("2026-06-23T08:00:00.000Z"), + origin: "COACH", + notifyHour: 7, + location: null, + nextDueAt: new Date("2026-06-16T19:00:00.000Z"), + lastSatisfiedAt: null, + enabled: true, + createdAt: now, + updatedAt: now, + deletedAt: null, + ...overrides, + } as MeasurementReminder; +} + +describe("toMeasurementReminderDto", () => { + it("echoes origin and endsOn (iOS contract)", () => { + const dto = toMeasurementReminderDto(row()); + expect(dto.origin).toBe("COACH"); + expect(dto.endsOn).toBe("2026-06-23T08:00:00.000Z"); + }); + + it("serialises a default VORSORGE open-ended reminder", () => { + const dto = toMeasurementReminderDto( + row({ origin: "VORSORGE", endsOn: null }), + ); + expect(dto.origin).toBe("VORSORGE"); + expect(dto.endsOn).toBeNull(); + }); +}); diff --git a/src/lib/measurement-reminders/dto.ts b/src/lib/measurement-reminders/dto.ts index 14ddd2239..d07453f53 100644 --- a/src/lib/measurement-reminders/dto.ts +++ b/src/lib/measurement-reminders/dto.ts @@ -15,6 +15,17 @@ export interface MeasurementReminderDtoShape { intervalDays: number | null; rrule: string | null; anchorDate: string | null; + /** + * v1.18.1 — optional course-window end (ISO-8601). NULL ⇒ open-ended. + * A Coach-suggested time-boxed protocol carries a non-NULL value and + * self-expires. + */ + endsOn: string | null; + /** + * v1.18.1 — provenance: `VORSORGE` (user-created) or `COACH` (minted from + * a Coach cadence suggestion). The UI labels the source. + */ + origin: "VORSORGE" | "COACH"; notifyHour: number; location: string | null; nextDueAt: string | null; @@ -34,6 +45,8 @@ export function toMeasurementReminderDto( intervalDays: row.intervalDays, rrule: row.rrule, anchorDate: row.anchorDate ? row.anchorDate.toISOString() : null, + endsOn: row.endsOn ? row.endsOn.toISOString() : null, + origin: row.origin, notifyHour: row.notifyHour, location: row.location, nextDueAt: row.nextDueAt ? row.nextDueAt.toISOString() : null, diff --git a/src/lib/measurement-reminders/scheduling.ts b/src/lib/measurement-reminders/scheduling.ts index 856448636..67415e15c 100644 --- a/src/lib/measurement-reminders/scheduling.ts +++ b/src/lib/measurement-reminders/scheduling.ts @@ -41,6 +41,13 @@ export interface ReminderScheduleInput { notifyHour: number; lastSatisfiedAt: Date | null; createdAt: Date; + /** + * v1.18.1 (Workstream C) — optional course-window end. NULL ⇒ open-ended + * (the existing behaviour). Non-NULL bounds a finite cadence: the + * recurrence engine stops producing occurrences past this instant, so a + * Coach-suggested time-boxed protocol (ESH/AHA 7-day BP) self-expires. + */ + endsOn?: Date | null; } function hourToHhmm(hour: number): string { @@ -86,7 +93,7 @@ export function buildReminderRecurrence( medication: { id: "measurement-reminder", startsOn, - endsOn: null, + endsOn: reminder.endsOn ?? null, oneShot: false, createdAt: reminder.createdAt, }, diff --git a/src/lib/validations/__tests__/coach-prefs.test.ts b/src/lib/validations/__tests__/coach-prefs.test.ts index 70b541655..cb3f8c6bc 100644 --- a/src/lib/validations/__tests__/coach-prefs.test.ts +++ b/src/lib/validations/__tests__/coach-prefs.test.ts @@ -171,4 +171,35 @@ describe("parseCoachPrefs", () => { "mood", ]); }); + + // v1.18.1 (Workstream C) — reminder-suggestion sub-shape. + it("a legacy blob (no reminderSuggestions) parses with the key absent", () => { + const out = parseCoachPrefs({ tone: "warm" }); + expect(out.reminderSuggestions).toBeUndefined(); + }); + + it("fills the reminderSuggestions defaults when the key is an empty object", () => { + const out = coachPrefsSchema.parse({ reminderSuggestions: {} }); + expect(out.reminderSuggestions).toEqual({ + enabled: true, + stopped: false, + dismissedCadences: [], + lastSuggestedAt: null, + }); + }); + + it("round-trips a populated reminderSuggestions block", () => { + const out = coachPrefsSchema.parse({ + reminderSuggestions: { + enabled: true, + stopped: false, + dismissedCadences: ["bp_7_2_2"], + lastSuggestedAt: "2026-06-16T12:00:00.000Z", + }, + }); + expect(out.reminderSuggestions?.dismissedCadences).toEqual(["bp_7_2_2"]); + expect(out.reminderSuggestions?.lastSuggestedAt).toBe( + "2026-06-16T12:00:00.000Z", + ); + }); }); diff --git a/src/lib/validations/coach-prefs.ts b/src/lib/validations/coach-prefs.ts index cef940132..958512d48 100644 --- a/src/lib/validations/coach-prefs.ts +++ b/src/lib/validations/coach-prefs.ts @@ -126,6 +126,43 @@ export const DEFAULT_COACH_CLUSTERS: ReadonlyArray = [ "medication", ]; +/** + * v1.18.1 (Workstream C) — Coach cadence-suggestion state. Persisted + * inside the same `coachPrefsJson` blob so the suggestion engine has a + * single, atomic place for the non-naggy machinery: + * + * - `enabled`: master opt-out for cadence suggestions (default ON; the + * toggle lives under Coach / Reminders settings). + * - `stopped`: the explicit "you measure enough — stop" path. Once the + * user picks it the Coach never suggests a cadence again until they + * re-enable. Distinct from `enabled:false` only in provenance (the user + * said "stop" from the card vs flipped the settings toggle); both + * suppress. + * - `dismissedCadences`: cadence ids the user dismissed. A dismissed + * cadence is never re-suggested (dismissal memory). + * - `lastSuggestedAt`: ISO instant of the last suggestion actually shown. + * Drives the cooldown (no second suggestion within the cooldown window). + * + * The shape is fully optional + defaulted so every legacy blob parses as + * "suggestions on, nothing dismissed". + */ +export const coachReminderSuggestionPrefsSchema = z.object({ + enabled: z.boolean().default(true), + stopped: z.boolean().default(false), + dismissedCadences: z.array(z.string().max(64)).max(32).default([]), + lastSuggestedAt: z.string().max(40).nullable().default(null), +}); +export type CoachReminderSuggestionPrefs = z.infer< + typeof coachReminderSuggestionPrefsSchema +>; + +export const DEFAULT_REMINDER_SUGGESTION_PREFS: CoachReminderSuggestionPrefs = { + enabled: true, + stopped: false, + dismissedCadences: [], + lastSuggestedAt: null, +}; + /** * Full preferences shape. Defaults are inlined into the schema so a * `safeParse({})` call returns the legacy v1.4.22 defaults — saves a @@ -144,6 +181,12 @@ export const coachPrefsSchema = z.object({ // — an empty array means "the user turned everything off", which is a // distinct, valid state from "never picked". dataClusters: z.array(coachDataClusterEnum).max(10).optional(), + // v1.18.1 (Workstream C) — cadence-suggestion state. Optional with NO + // top-level default so a legacy blob (and `parse({})`) stays byte- + // identical: an absent key reads as `undefined`, and call sites fall + // back to `DEFAULT_REMINDER_SUGGESTION_PREFS`. When the key IS present, + // its inner fields default (so `{}` fills to the all-on shape). + reminderSuggestions: coachReminderSuggestionPrefsSchema.optional(), }); export type CoachPrefs = z.infer; diff --git a/src/lib/validations/coach-reminder-suggestion.ts b/src/lib/validations/coach-reminder-suggestion.ts new file mode 100644 index 000000000..6e3bb55dc --- /dev/null +++ b/src/lib/validations/coach-reminder-suggestion.ts @@ -0,0 +1,24 @@ +/** + * v1.18.1 (Workstream C) — request body for the Coach cadence-suggestion + * action endpoint (`POST /api/coach/reminder-suggestions`). + * + * The client sends ONLY the cadence id and the action it took; the server + * resolves the actual cadence (metric + schedule + course window) from the + * closed catalog, so the client can never widen a cadence or inject a + * schedule. + * + * - `accept` → create a `MeasurementReminder` with `origin: COACH`. + * - `dismiss` → record dismissal memory (never re-suggest this cadence). + * - `stop` → the explicit "you measure enough — stop" path (suppress + * every future cadence suggestion until re-enabled). + */ +import { z } from "zod/v4"; + +export const coachReminderSuggestionActionSchema = z.object({ + cadenceId: z.string().trim().min(1).max(64), + action: z.enum(["accept", "dismiss", "stop"]), +}); + +export type CoachReminderSuggestionAction = z.infer< + typeof coachReminderSuggestionActionSchema +>; diff --git a/src/lib/validations/measurement-reminders.ts b/src/lib/validations/measurement-reminders.ts index e8fa7c5a9..fc4f55b50 100644 --- a/src/lib/validations/measurement-reminders.ts +++ b/src/lib/validations/measurement-reminders.ts @@ -169,6 +169,8 @@ export const measurementReminderDto = z intervalDays: z.number().int().nullable(), rrule: z.string().nullable(), anchorDate: z.iso.datetime({ offset: true }).nullable(), + endsOn: z.iso.datetime({ offset: true }).nullable(), + origin: z.enum(["VORSORGE", "COACH"]), notifyHour: z.number().int(), location: z.string().nullable(), nextDueAt: z.iso.datetime({ offset: true }).nullable(), @@ -180,5 +182,5 @@ export const measurementReminderDto = z .meta({ id: "MeasurementReminderDTO", description: - "A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries measurementType=null and resolves only on a manual satisfy.", + "A Vorsorge reminder. nextDueAt is server-computed (server-authoritative). A free-text reminder carries measurementType=null and resolves only on a manual satisfy. origin distinguishes a user-created (VORSORGE) reminder from one minted by a Coach cadence suggestion (COACH); endsOn bounds a finite course window (null = open-ended).", });