From b7a6b4c8985bf28848edcf86c98a9fba2b723eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 16 Jul 2026 22:30:46 +0200 Subject: [PATCH] feat(coach): close the plan check-in loop on the Today rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every accepted coach plan now earns a gentle weekly check-in. Confirming a plan (proposed → active) without an explicit review date defaults one to a week out, so a plan is always reviewed, not only the coach's own experiments. The daily digest emits a single `coach_checkin` rail item when a standing plan's review has come due — its pinned review date, or a defaulted activation + 7 days for plans that predate it, or the read-back state a reviewed plan carries. Capped at one check-in a day across all plans (earliest-due wins); the rest wait for a following day. It reads existing plan state only — no fresh generation, nothing warms on mount, and it is gated on the coach module. The card offers three one-tap actions through the existing plan-lifecycle route: keep it (re-arm for another week), adjust it (open the coach), or let it go (a guilt-free, respected end — the calm inversion of a streak). An ignored check-in sits quiet and stops resurfacing after about two cycles; its status is never changed behind the user's back. Copy lands in all six locales. --- e2e/dashboard.spec.ts | 25 +++ messages/de.json | 10 +- messages/en.json | 10 +- messages/es.json | 10 +- messages/fr.json | 10 +- messages/it.json | 10 +- messages/pl.json | 10 +- src/app/api/coach/plans/[id]/route.ts | 18 +++ .../api/coach/plans/__tests__/route.test.ts | 63 +++++++- .../daily/__tests__/today-hero.test.tsx | 8 +- src/components/daily/today-hero.tsx | 28 +++- src/hooks/use-coach-checkin.ts | 59 +++++++ src/lib/daily/__tests__/digest.test.ts | 151 ++++++++++++++++++ src/lib/daily/digest.ts | 142 +++++++++++++++- src/lib/daily/load-digest.ts | 121 +++++++++++--- 15 files changed, 643 insertions(+), 32 deletions(-) create mode 100644 src/hooks/use-coach-checkin.ts diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts index 53e680acd..017051510 100644 --- a/e2e/dashboard.spec.ts +++ b/e2e/dashboard.spec.ts @@ -130,6 +130,31 @@ test.describe("authenticated dashboard render", () => { }, ], }, + // S3 — a coach check-in card (keep / adjust / let-go). Rendered + // only; the mutating taps PATCH the plan route, which this spec + // never clicks, so it stays green without a plan-route stub. + { + kind: "coach_checkin", + title: "Time to check in on a plan", + body: "About a week ago you set: “every morning → weigh in”.", + status: "info", + moduleKey: "coach", + actions: [ + { + labelKey: "daily.action.checkinKeep", + intent: "coach.checkin.keep:p1", + }, + { + labelKey: "daily.action.checkinAdjust", + intent: "coach.checkin.adjust", + href: "/coach", + }, + { + labelKey: "daily.action.checkinLetGo", + intent: "coach.checkin.letGo:p1", + }, + ], + }, ], }, error: null, diff --git a/messages/de.json b/messages/de.json index 0a63548e1..f9103f0ec 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8518,12 +8518,20 @@ "syncIssue": { "title": "Synchronisierung braucht Aufmerksamkeit", "body": "{integration} synchronisiert nicht — verbinde neu, um deine Daten aktuell zu halten." + }, + "coachCheckin": { + "title": "Zeit, einen Plan anzuschauen", + "body": "Es ist etwa eine Woche her, seit du diesen Plan gesetzt hast — behalte ihn, passe ihn an oder lass ihn los. Ganz ohne Druck.", + "bodyPlan": "Vor etwa einer Woche hast du dir vorgenommen: „{plan}“. Behalte es, passe es an oder lass es los — ganz ohne Druck." } }, "action": { "logDose": "Dosis erfassen", "reconnect": "Neu verbinden", - "viewCheckups": "Vorsorge ansehen" + "viewCheckups": "Vorsorge ansehen", + "checkinKeep": "Behalten", + "checkinAdjust": "Anpassen", + "checkinLetGo": "Loslassen" }, "today": { "scoreLabel": "Gesundheitsscore", diff --git a/messages/en.json b/messages/en.json index a040596f9..d40e57f56 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8518,12 +8518,20 @@ "syncIssue": { "title": "Sync needs attention", "body": "{integration} isn't syncing — reconnect to keep your data current." + }, + "coachCheckin": { + "title": "Time to check in on a plan", + "body": "It's been about a week since you set this plan — keep it, adjust it, or let it go. No pressure either way.", + "bodyPlan": "About a week ago you set: “{plan}”. Keep it, adjust it, or let it go — no pressure either way." } }, "action": { "logDose": "Log dose", "reconnect": "Reconnect", - "viewCheckups": "View check-ups" + "viewCheckups": "View check-ups", + "checkinKeep": "Keep it", + "checkinAdjust": "Adjust", + "checkinLetGo": "Let it go" }, "today": { "scoreLabel": "Health score", diff --git a/messages/es.json b/messages/es.json index 7fb730a3e..c04042712 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8518,12 +8518,20 @@ "syncIssue": { "title": "La sincronización necesita atención", "body": "{integration} no se está sincronizando: vuelve a conectarlo para mantener tus datos al día." + }, + "coachCheckin": { + "title": "Es buen momento para revisar un plan", + "body": "Ha pasado alrededor de una semana desde que definiste este plan: consérvalo, ajústalo o déjalo ir. Sin presión.", + "bodyPlan": "Hace aproximadamente una semana te propusiste: «{plan}». Consérvalo, ajústalo o déjalo ir, sin presión." } }, "action": { "logDose": "Registrar dosis", "reconnect": "Reconectar", - "viewCheckups": "Ver revisiones" + "viewCheckups": "Ver revisiones", + "checkinKeep": "Conservar", + "checkinAdjust": "Ajustar", + "checkinLetGo": "Dejarlo ir" }, "today": { "scoreLabel": "Puntuación de salud", diff --git a/messages/fr.json b/messages/fr.json index 543e12aec..7a877c52b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8518,12 +8518,20 @@ "syncIssue": { "title": "La synchronisation demande votre attention", "body": "{integration} ne se synchronise pas — reconnecte-le pour garder tes données à jour." + }, + "coachCheckin": { + "title": "Il est temps de revoir un plan", + "body": "Cela fait environ une semaine que vous avez défini ce plan : gardez-le, ajustez-le ou laissez-le. Sans pression.", + "bodyPlan": "Il y a environ une semaine, vous vous étiez fixé : « {plan} ». Gardez-le, ajustez-le ou laissez-le, sans pression." } }, "action": { "logDose": "Enregistrer la dose", "reconnect": "Reconnecter", - "viewCheckups": "Voir les examens" + "viewCheckups": "Voir les examens", + "checkinKeep": "Garder", + "checkinAdjust": "Ajuster", + "checkinLetGo": "Laisser tomber" }, "today": { "scoreLabel": "Score de santé", diff --git a/messages/it.json b/messages/it.json index 7102125f2..2aab1764c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8518,12 +8518,20 @@ "syncIssue": { "title": "La sincronizzazione richiede attenzione", "body": "{integration} non si sincronizza — riconnettilo per mantenere aggiornati i tuoi dati." + }, + "coachCheckin": { + "title": "È il momento di rivedere un piano", + "body": "È passata circa una settimana da quando hai definito questo piano: mantienilo, modificalo o lascialo andare. Senza pressioni.", + "bodyPlan": "Circa una settimana fa ti eri proposto: «{plan}». Mantienilo, modificalo o lascialo andare, senza pressioni." } }, "action": { "logDose": "Registra dose", "reconnect": "Riconnetti", - "viewCheckups": "Vedi controlli" + "viewCheckups": "Vedi controlli", + "checkinKeep": "Mantieni", + "checkinAdjust": "Modifica", + "checkinLetGo": "Lascia andare" }, "today": { "scoreLabel": "Punteggio di salute", diff --git a/messages/pl.json b/messages/pl.json index 2eb11e0be..6f1b22347 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8518,12 +8518,20 @@ "syncIssue": { "title": "Synchronizacja wymaga uwagi", "body": "{integration} się nie synchronizuje — połącz ponownie, aby dane były aktualne." + }, + "coachCheckin": { + "title": "Czas przyjrzeć się planowi", + "body": "Minął mniej więcej tydzień, odkąd ustaliłeś ten plan — zachowaj go, dostosuj lub odpuść. Bez presji.", + "bodyPlan": "Około tygodnia temu postanowiłeś: „{plan}”. Zachowaj to, dostosuj lub odpuść — bez presji." } }, "action": { "logDose": "Zapisz dawkę", "reconnect": "Połącz ponownie", - "viewCheckups": "Zobacz badania" + "viewCheckups": "Zobacz badania", + "checkinKeep": "Zachowaj", + "checkinAdjust": "Dostosuj", + "checkinLetGo": "Odpuść" }, "today": { "scoreLabel": "Wynik zdrowia", diff --git a/src/app/api/coach/plans/[id]/route.ts b/src/app/api/coach/plans/[id]/route.ts index f89226ee8..f21351a5c 100644 --- a/src/app/api/coach/plans/[id]/route.ts +++ b/src/app/api/coach/plans/[id]/route.ts @@ -34,6 +34,7 @@ import { prisma } from "@/lib/db"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; +import { COACH_CHECKIN_REVIEW_DAYS } from "@/lib/daily/digest"; import { coachPlanPatchSchema } from "@/lib/validations/coach-plan"; interface RouteCtx { @@ -94,6 +95,23 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => { : null; } + // S3 (§2.3.1) — every accepted plan earns a check-in. When the coach + // activates a plan and set no review date of its own, default `reviewDate` to + // +COACH_CHECKIN_REVIEW_DAYS so the Today check-in card fires in a week. Read + // the current row first so an explicitly-pinned review date is never + // clobbered; the read stays owner-scoped, so it leaks no cross-account state. + if (parsed.data.status === "active" && parsed.data.reviewDate === undefined) { + const current = await prisma.coachPlan.findFirst({ + where: { id, userId: user.id, deletedAt: null }, + select: { reviewDate: true }, + }); + if (current && current.reviewDate === null) { + data.reviewDate = new Date( + Date.now() + COACH_CHECKIN_REVIEW_DAYS * 86_400_000, + ); + } + } + // `updateMany` (not `update`) so an unknown / cross-user / already-deleted id // is a 0-count no-op rather than a P2025 throw — the existence channel never // leaks across accounts. diff --git a/src/app/api/coach/plans/__tests__/route.test.ts b/src/app/api/coach/plans/__tests__/route.test.ts index a41f9d075..f0b871460 100644 --- a/src/app/api/coach/plans/__tests__/route.test.ts +++ b/src/app/api/coach/plans/__tests__/route.test.ts @@ -315,7 +315,68 @@ describe("PATCH /api/coach/plans/[id]", () => { expect(arg.where.id).toBe("p1"); expect(arg.where.userId).toBe("user-1"); expect(arg.where.deletedAt).toBeNull(); - // Only the lifecycle field is written — never metric / encrypted text. + // S3 (§2.3.1): activating a plan the coach never dated defaults a review + // checkpoint ~7d out. Only lifecycle fields are written (status + + // reviewDate) — never metric / encrypted text (no mass assignment). + expect(new Set(Object.keys(arg.data))).toEqual( + new Set(["status", "reviewDate"]), + ); + expect(arg.data.status).toBe("active"); + expect(arg.data.reviewDate).toBeInstanceOf(Date); + const days = + ((arg.data.reviewDate as Date).getTime() - Date.now()) / 86_400_000; + expect(days).toBeGreaterThan(6.5); + expect(days).toBeLessThan(7.5); + }); + + it("does NOT clobber a review date the caller pinned on activate", async () => { + vi.mocked(prisma.coachPlan.updateMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.coachPlan.findFirst).mockResolvedValue({ + id: "p1", + metric: "WEIGHT", + ifCueEncrypted: bytes("every morning"), + thenActionEncrypted: bytes("weigh in"), + targetEncrypted: null, + status: "active", + reviewDate: new Date("2026-08-01T00:00:00Z"), + createdAt: new Date("2026-06-01T00:00:00Z"), + updatedAt: new Date("2026-06-03T00:00:00Z"), + } as never); + + const res = await callPatch({ + status: "active", + reviewDate: "2026-08-01T00:00:00.000Z", + }); + expect(res.status).toBe(200); + const arg = vi.mocked(prisma.coachPlan.updateMany).mock.calls[0]?.[0] as { + data: { reviewDate: Date }; + }; + // The pinned date is honoured verbatim, not the +7d default. + expect(arg.data.reviewDate.toISOString()).toBe("2026-08-01T00:00:00.000Z"); + }); + + it("does NOT default a review date when marking a plan met", async () => { + vi.mocked(prisma.coachPlan.updateMany).mockResolvedValue({ + count: 1, + } as never); + vi.mocked(prisma.coachPlan.findFirst).mockResolvedValue({ + id: "p1", + metric: "WEIGHT", + ifCueEncrypted: bytes("every morning"), + thenActionEncrypted: bytes("weigh in"), + targetEncrypted: null, + status: "met", + reviewDate: null, + createdAt: new Date("2026-06-01T00:00:00Z"), + updatedAt: new Date("2026-06-03T00:00:00Z"), + } as never); + + await callPatch({ status: "met" }); + const arg = vi.mocked(prisma.coachPlan.updateMany).mock.calls[0]?.[0] as { + data: Record; + }; expect(Object.keys(arg.data)).toEqual(["status"]); }); diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index a6591fe7d..fa43c9722 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -1,14 +1,20 @@ import { describe, it, expect } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; import { TodayHero } from "../today-hero"; import type { DailyDigest } from "@/lib/daily/digest"; import type { PriorityItem } from "@/lib/daily/priority-item"; +// The hero now wires the coach check-in card's keep / let-go taps through +// `useCoachCheckinAction`, so it needs a QueryClient in the tree. function render(node: React.ReactNode, locale: "en" | "de" = "en") { + const client = new QueryClient(); return renderToStaticMarkup( - {node}, + + {node} + , ); } diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index 34522a09c..f0d343225 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -36,9 +36,14 @@ import { ScoreRing } from "@/components/insights/derived/score-ring"; import type { ScoreBand } from "@/components/insights/derived/band-tokens"; import { ProseBlocks } from "@/components/insights/prose-blocks"; import { PriorityCard } from "@/components/daily/priority-card"; +import { useCoachCheckinAction } from "@/hooks/use-coach-checkin"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; -import type { DailyDigest } from "@/lib/daily/digest"; +import { + COACH_CHECKIN_KEEP_INTENT, + COACH_CHECKIN_LETGO_INTENT, + type DailyDigest, +} from "@/lib/daily/digest"; /** Format the server-computed score delta as a signed, muted chip string. */ function formatDelta( @@ -53,6 +58,21 @@ function formatDelta( export function TodayHero({ digest }: { digest: DailyDigest }) { const { t } = useTranslations(); + const { keep, letGo } = useCoachCheckinAction(); + + // The coach check-in card's keep / let-go intents carry the plan id after the + // ":" (a closed two-intent allowlist); adjust is an href handled by the card + // as a , so it never lands here. Every other rail kind is pure + // navigation (dose / sync / preventive), so their taps never call this. + const handleAction = (intent: string) => { + const keepPrefix = `${COACH_CHECKIN_KEEP_INTENT}:`; + const letGoPrefix = `${COACH_CHECKIN_LETGO_INTENT}:`; + if (intent.startsWith(keepPrefix)) { + keep.mutate(intent.slice(keepPrefix.length)); + } else if (intent.startsWith(letGoPrefix)) { + letGo.mutate(intent.slice(letGoPrefix.length)); + } + }; const hasScore = digest.score !== null; const hasItems = digest.worthALook.length > 0; @@ -174,7 +194,11 @@ export function TodayHero({ digest }: { digest: DailyDigest }) {
{digest.worthALook.map((item, i) => ( - + ))}
diff --git a/src/hooks/use-coach-checkin.ts b/src/hooks/use-coach-checkin.ts new file mode 100644 index 000000000..b58f741f2 --- /dev/null +++ b/src/hooks/use-coach-checkin.ts @@ -0,0 +1,59 @@ +"use client"; + +/** + * S3 — the coach check-in card's keep / let-go actions (§2.3). + * + * The Today rail surfaces a `coach_checkin` `PriorityItem` whose two mutating + * one-tap actions close the coach loop through the EXISTING plan-lifecycle + * route (`PATCH /api/coach/plans/[id]`) — no new backend surface: + * - keep → re-arm: status `active` + `reviewDate` pushed out one more cycle, + * so the check-in comes back in a week rather than the same day; + * - letGo → guilt-free retirement: status `abandoned`, a terminal, respected + * outcome (the calm inversion of a streak). The card never nags. + * + * "Adjust" is navigation (an `href` into the coach) handled by `PriorityCard` + * as a `` — it never reaches this hook. + * + * Both invalidate the daily digest (so the acted-on card leaves the rail) and + * every plans-list slot (so the /coach/plans ledger stays in step). Keys route + * through the centralised factory — no bare arrays. + */ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiPatch } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { COACH_CHECKIN_REVIEW_DAYS } from "@/lib/daily/digest"; + +export function useCoachCheckinAction() { + const queryClient = useQueryClient(); + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.dailyDigest() }); + void queryClient.invalidateQueries({ queryKey: queryKeys.coachPlansAll() }); + }; + + const keep = useMutation({ + mutationFn: async (planId: string) => { + const reviewDate = new Date( + Date.now() + COACH_CHECKIN_REVIEW_DAYS * 86_400_000, + ).toISOString(); + await apiPatch(`/api/coach/plans/${encodeURIComponent(planId)}`, { + status: "active", + reviewDate, + }); + return planId; + }, + onSuccess: invalidate, + }); + + const letGo = useMutation({ + mutationFn: async (planId: string) => { + await apiPatch(`/api/coach/plans/${encodeURIComponent(planId)}`, { + status: "abandoned", + }); + return planId; + }, + onSuccess: invalidate, + }); + + return { keep, letGo }; +} diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index 95d2b32d1..d2a147af0 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -5,7 +5,12 @@ import type { DailyBriefing } from "@/lib/ai/schema"; import type { MedsTodayBlock } from "@/lib/dashboard/meds-today"; import { buildDailyDigest, + COACH_CHECKIN_KEEP_INTENT, + COACH_CHECKIN_LETGO_INTENT, + COACH_CHECKIN_RESURFACE_DAYS, + COACH_CHECKIN_REVIEW_DAYS, MAX_WORTH_A_LOOK, + type DailyDigestCoachPlan, type DailyDigestInput, } from "@/lib/daily/digest"; @@ -50,6 +55,25 @@ function input(over: Partial = {}): DailyDigestInput { sleepLastSeenDaysAgo: 0, syncIssues: [], preventiveDue: [], + coachPlans: [], + ...over, + }; +} + +const DAY = 86_400_000; + +function plan(over: Partial = {}): DailyDigestCoachPlan { + // Default: an active plan whose defaulted review (createdAt + 7d) is due. + const createdAt = new Date( + NOW.getTime() - (COACH_CHECKIN_REVIEW_DAYS + 1) * DAY, + ); + return { + id: "p1", + status: "active", + reviewDate: null, + createdAt, + updatedAt: createdAt, + planText: "every morning → weigh in", ...over, }; } @@ -233,3 +257,130 @@ describe("buildDailyDigest — worth-a-look rail item builders", () => { expect(d.worthALook).toEqual([]); }); }); + +describe("buildDailyDigest — coach check-in (S3)", () => { + function checkin(d: ReturnType) { + return d.worthALook.find((i) => i.kind === "coach_checkin"); + } + + it("emits a check-in when an active plan's defaulted review has come due", () => { + const d = buildDailyDigest(input({ coachPlans: [plan()] }), t); + const item = checkin(d); + expect(item).toBeDefined(); + expect(item?.status).toBe("info"); + expect(item?.moduleKey).toBe("coach"); + expect(item?.actions).toHaveLength(3); + // The plan's own words are echoed in the body. + expect(item?.body).toContain("every morning → weigh in"); + // Keep / let-go carry the plan id; adjust is a plain navigation href. + expect(item?.actions[0].intent).toBe(`${COACH_CHECKIN_KEEP_INTENT}:p1`); + expect(item?.actions[1].href).toBe("/coach"); + expect(item?.actions[2].intent).toBe(`${COACH_CHECKIN_LETGO_INTENT}:p1`); + }); + + it("emits a review-due check-in from a plan's pinned reviewDate", () => { + const reviewDate = new Date(NOW.getTime() - DAY); + const d = buildDailyDigest( + input({ coachPlans: [plan({ reviewDate })] }), + t, + ); + expect(checkin(d)).toBeDefined(); + }); + + it("emits a check-in for a reviewed plan (post-sweep read-back state)", () => { + const d = buildDailyDigest( + input({ + coachPlans: [ + plan({ + status: "reviewed", + reviewDate: null, + updatedAt: new Date(NOW.getTime() - DAY), + }), + ], + }), + t, + ); + expect(checkin(d)).toBeDefined(); + }); + + it("falls back to a generic body when the plan text is undecryptable", () => { + const d = buildDailyDigest( + input({ coachPlans: [plan({ planText: null })] }), + t, + ); + expect(checkin(d)?.body).toBe( + "It's been about a week since you set this plan — keep it, adjust it, or let it go. No pressure either way.", + ); + }); + + it("does NOT emit a check-in before the review is due", () => { + const d = buildDailyDigest( + input({ + coachPlans: [plan({ reviewDate: new Date(NOW.getTime() + DAY) })], + }), + t, + ); + expect(checkin(d)).toBeUndefined(); + }); + + it("does NOT emit a check-in when the coach module is off", () => { + const d = buildDailyDigest( + input({ modules: { coach: false }, coachPlans: [plan()] }), + t, + ); + expect(checkin(d)).toBeUndefined(); + }); + + it("emits none when there are no standing plans", () => { + const d = buildDailyDigest(input({ coachPlans: [] }), t); + expect(checkin(d)).toBeUndefined(); + }); + + it("stops resurfacing after the resurface window (quiet retirement)", () => { + const stale = new Date( + NOW.getTime() - (COACH_CHECKIN_RESURFACE_DAYS + 2) * DAY, + ); + const d = buildDailyDigest( + input({ coachPlans: [plan({ reviewDate: stale })] }), + t, + ); + expect(checkin(d)).toBeUndefined(); + }); + + it("caps at ONE check-in per day, surfacing the earliest-due plan", () => { + const older = new Date(NOW.getTime() - 5 * DAY); + const newer = new Date(NOW.getTime() - 1 * DAY); + const d = buildDailyDigest( + input({ + coachPlans: [ + plan({ id: "recent", reviewDate: newer }), + plan({ id: "oldest", reviewDate: older }), + ], + }), + t, + ); + const items = d.worthALook.filter((i) => i.kind === "coach_checkin"); + expect(items).toHaveLength(1); + expect(items[0].actions[0].intent).toBe( + `${COACH_CHECKIN_KEEP_INTENT}:oldest`, + ); + }); + + it("does not displace an overdue dose from the bounded rail", () => { + const d = buildDailyDigest( + input({ + medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }), + syncIssues: [ + { integration: "withings", state: "error_reauth" }, + { integration: "moodlog", state: "parked" }, + ], + coachPlans: [plan()], + }), + t, + ); + // dose + 2 sync fill the cap; the check-in waits for a following day. + expect(d.worthALook).toHaveLength(MAX_WORTH_A_LOOK); + expect(checkin(d)).toBeUndefined(); + expect(d.worthALook[0].kind).toBe("dose_window"); + }); +}); diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 6765aba33..131c012e4 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -34,6 +34,36 @@ export const MAX_WORTH_A_LOOK = 3; /** Trim a briefing-lead sentence to a lock-screen-friendly length. */ const MAX_LINE_LENGTH = 160; +const MS_PER_DAY = 86_400_000; + +/** + * S3 — coach check-in loop (§2.3). Days added to a plan's activation when the + * coach set no review date: every accepted plan earns a check-in, not only the + * ones the coach explicitly dated. The PATCH-to-`active` route defaults + * `reviewDate` to `+COACH_CHECKIN_REVIEW_DAYS`; this constant is the one source. + */ +export const COACH_CHECKIN_REVIEW_DAYS = 7; + +/** + * After a check-in comes due, it resurfaces for at most this many days before + * it stops appearing on its own — the calm inversion of a streak (§2.3.3): an + * ignored check-in sits quiet after ~two cycles rather than nagging forever, + * and the plan's status is NEVER changed behind the user's back. Only an + * explicit keep / let-go moves the plan; silence just retires the card. + */ +export const COACH_CHECKIN_RESURFACE_DAYS = 14; + +/** + * Closed allowlist of the check-in card's two MUTATING intents (§2.3.2). The + * generic, id-less `PriorityCard` forwards only a single `intent` string, so + * the target plan id is appended after the ":" — the Today handler recovers it + * and PATCHes the existing plan-lifecycle route. "Adjust" is navigation (an + * `href` into the coach), so it carries no plan id and never mutates here. + */ +export const COACH_CHECKIN_KEEP_INTENT = "coach.checkin.keep"; +export const COACH_CHECKIN_LETGO_INTENT = "coach.checkin.letGo"; +export const COACH_CHECKIN_ADJUST_INTENT = "coach.checkin.adjust"; + type Translate = ServerTranslator["t"]; /** Module map in the registry's DISABLED-allowlist shape (missing = enabled). */ @@ -62,6 +92,25 @@ export interface DailyDigestPreventiveDue { label: string; } +/** + * A standing coach plan the IO seam offers as a check-in candidate (§2.3). The + * builder computes due-ness deterministically from the plain columns — it never + * needs a fresh AI call, reading only the existing plan lifecycle. `planText` + * is the plan's own if→then prose (decrypted fault-isolated in the IO seam, or + * null when its key rotated out) so the card can echo the user's own words. + */ +export interface DailyDigestCoachPlan { + id: string; + /** Lifecycle status — only `active` / `reviewed` reach the builder. */ + status: string; + /** The coach-pinned review checkpoint, or null (then defaulted, see below). */ + reviewDate: Date | null; + createdAt: Date; + updatedAt: Date; + /** Decrypted "if cue → then action" prose, or null when undecryptable. */ + planText: string | null; +} + /** The fully-resolved, IO-free input the composer folds into a digest. */ export interface DailyDigestInput { now: Date; @@ -74,6 +123,8 @@ export interface DailyDigestInput { sleepLastSeenDaysAgo: number | null; syncIssues: DailyDigestSyncIssue[]; preventiveDue: DailyDigestPreventiveDue[]; + /** Standing coach plans (active + reviewed) — check-in candidates (§2.3). */ + coachPlans: DailyDigestCoachPlan[]; } export interface DailyDigest { @@ -195,6 +246,86 @@ function buildPreventiveCareItem( }; } +/** + * The instant a plan's check-in came due (§2.3). A coach-pinned `reviewDate` + * wins. Otherwise, a `reviewed` plan lost its `reviewDate` to the daily sweep + * when the read-back fired, so its `updatedAt` (the flip moment) is when the + * check-in came due. A legacy `active` plan the coach never dated defaults to + * activation + `COACH_CHECKIN_REVIEW_DAYS` — the same rule the PATCH route now + * writes forward, applied read-side for plans that predate it. + */ +function checkinDueAt(plan: DailyDigestCoachPlan): number { + if (plan.reviewDate) return plan.reviewDate.getTime(); + if (plan.status === "reviewed") return plan.updatedAt.getTime(); + return plan.createdAt.getTime() + COACH_CHECKIN_REVIEW_DAYS * MS_PER_DAY; +} + +/** + * A plan is check-in-due when its due instant has passed AND it is still within + * the resurface window. Past the window the card retires quietly — the plan's + * status is untouched (never abandoned behind the user's back); a keep re-arms + * it, a let-go ends it, silence just stops the card. + */ +function isCheckinDue(plan: DailyDigestCoachPlan, now: number): boolean { + const dueAt = checkinDueAt(plan); + if (dueAt > now) return false; + return now - dueAt <= COACH_CHECKIN_RESURFACE_DAYS * MS_PER_DAY; +} + +/** + * The one coach check-in card (§2.3). Gated on the `coach` module. Capped at + * ONE per day across every plan: the earliest-due check-in wins, the rest wait + * for a following day. Reads existing plan state only — never a fresh AI call. + * Three one-tap actions map to the plan lifecycle: keep (re-arm), adjust + * (navigate into the coach), let go (guilt-free retirement). + */ +function buildCoachCheckinItem( + plans: DailyDigestCoachPlan[], + modules: DigestModuleMap, + now: Date, + t: Translate, +): PriorityItem | null { + if (!moduleEnabled(modules, "coach")) return null; + const nowMs = now.getTime(); + const due = plans + .filter((p) => isCheckinDue(p, nowMs)) + .sort((a, b) => { + const byDue = checkinDueAt(a) - checkinDueAt(b); + if (byDue !== 0) return byDue; + // Deterministic tie-break so the same day always surfaces the same card. + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; + }); + const plan = due[0]; + if (!plan) return null; + + const body = plan.planText + ? t("daily.item.coachCheckin.bodyPlan", { plan: plan.planText }) + : t("daily.item.coachCheckin.body"); + + return { + kind: "coach_checkin", + title: t("daily.item.coachCheckin.title"), + body, + status: "info", + actions: [ + { + labelKey: "daily.action.checkinKeep", + intent: `${COACH_CHECKIN_KEEP_INTENT}:${plan.id}`, + }, + { + labelKey: "daily.action.checkinAdjust", + intent: COACH_CHECKIN_ADJUST_INTENT, + href: "/coach", + }, + { + labelKey: "daily.action.checkinLetGo", + intent: `${COACH_CHECKIN_LETGO_INTENT}:${plan.id}`, + }, + ], + moduleKey: "coach", + }; +} + /** * The push / lock-screen line: prefer the warmer cached briefing lead, fall * back to the top signal's headline, then a deterministic score floor, then @@ -227,11 +358,20 @@ export function buildDailyDigest( const briefingLead = firstSentence(input.briefing?.paragraph); // Priority order: an overdue dose is the most time-sensitive daily action, a - // broken sync next, a preventive check-up least urgent. Bounded to 3. + // broken sync next, then the calm coach check-in, a preventive check-up + // least urgent. Bounded to 3 — a check-in the cap crowds out resurfaces on a + // following day (within its window), never lost. const worthALook: PriorityItem[] = []; const dose = buildDoseWindowItem(input.medsToday, input.modules, t); if (dose) worthALook.push(dose); worthALook.push(...buildSyncIssueItems(input.syncIssues, t)); + const checkin = buildCoachCheckinItem( + input.coachPlans, + input.modules, + input.now, + t, + ); + if (checkin) worthALook.push(checkin); const preventive = buildPreventiveCareItem(input.preventiveDue, t); if (preventive) worthALook.push(preventive); diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index f00632900..76f021406 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -20,9 +20,11 @@ import { resolveModuleMap } from "@/lib/modules/gate"; import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; +import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { buildDailyDigest, type DailyDigest, + type DailyDigestCoachPlan, type DailyDigestPreventiveDue, type DailyDigestScore, type DailyDigestSyncIssue, @@ -34,6 +36,45 @@ const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const; /** Defensive cap on how many overdue reminders we read for the rail summary. */ const PREVENTIVE_DUE_READ_LIMIT = 20; +/** Standing plan states that can carry a check-in (§2.3). */ +const CHECKIN_PLAN_STATES = ["active", "reviewed"] as const; + +/** Cap on plans scanned for the (one/day) check-in candidate. */ +const CHECKIN_PLAN_READ_LIMIT = 50; + +interface CoachPlanRow { + id: string; + status: string; + reviewDate: Date | null; + createdAt: Date; + updatedAt: Date; + ifCueEncrypted: Uint8Array; + thenActionEncrypted: Uint8Array; +} + +/** + * Decrypt a plan's if→then prose fault-isolated (an undecryptable row keeps a + * null text, never throws) so the check-in card can echo the user's own words. + */ +function toCoachPlanCandidate(row: CoachPlanRow): DailyDigestCoachPlan { + let planText: string | null = null; + try { + planText = `${decryptFromBytes(row.ifCueEncrypted)} → ${decryptFromBytes( + row.thenActionEncrypted, + )}`; + } catch { + planText = null; + } + return { + id: row.id, + status: row.status, + reviewDate: row.reviewDate, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + planText, + }; +} + function resolveLocale(locale: string | null | undefined): Locale { return locales.includes(locale as Locale) ? (locale as Locale) @@ -44,27 +85,54 @@ export async function loadDailyDigest( user: User, now: Date = new Date(), ): Promise { - const [{ body: snapshot, locale }, modules, syncRows, dueReminders] = - await Promise.all([ - readDashboardSnapshotCached(user), - resolveModuleMap(user.id), - prisma.integrationStatus.findMany({ - where: { userId: user.id, state: { in: [...SYNC_ISSUE_STATES] } }, - select: { integration: true, state: true }, - orderBy: { integration: "asc" }, - }), - prisma.measurementReminder.findMany({ - where: { - userId: user.id, - enabled: true, - deletedAt: null, - nextDueAt: { not: null, lte: now }, - }, - select: { label: true }, - orderBy: { nextDueAt: "asc" }, - take: PREVENTIVE_DUE_READ_LIMIT, - }), - ]); + const [ + { body: snapshot, locale }, + modules, + syncRows, + dueReminders, + planRows, + ] = await Promise.all([ + readDashboardSnapshotCached(user), + resolveModuleMap(user.id), + prisma.integrationStatus.findMany({ + where: { userId: user.id, state: { in: [...SYNC_ISSUE_STATES] } }, + select: { integration: true, state: true }, + orderBy: { integration: "asc" }, + }), + prisma.measurementReminder.findMany({ + where: { + userId: user.id, + enabled: true, + deletedAt: null, + nextDueAt: { not: null, lte: now }, + }, + select: { label: true }, + orderBy: { nextDueAt: "asc" }, + take: PREVENTIVE_DUE_READ_LIMIT, + }), + // Standing plans for the (one/day) check-in candidate. The plaintext + // columns decide due-ness; the encrypted prose is only decrypted below, + // and only for a coach-enabled account (disabled-module data never + // enters the digest — the builder also drops it, this skips the decrypt). + prisma.coachPlan.findMany({ + where: { + userId: user.id, + deletedAt: null, + status: { in: [...CHECKIN_PLAN_STATES] }, + }, + select: { + id: true, + status: true, + reviewDate: true, + createdAt: true, + updatedAt: true, + ifCueEncrypted: true, + thenActionEncrypted: true, + }, + orderBy: { updatedAt: "desc" }, + take: CHECKIN_PLAN_READ_LIMIT, + }), + ]); const score: DailyDigestScore | null = snapshot.healthScore ? { @@ -86,6 +154,13 @@ export async function loadDailyDigest( label: row.label, })); + // Only decrypt plan prose for a coach-enabled account; the builder gates on + // the module too, so a disabled coach never surfaces a check-in either way. + const coachEnabled = modules.coach !== false; + const coachPlans: DailyDigestCoachPlan[] = coachEnabled + ? planRows.map((row) => toCoachPlanCandidate(row as CoachPlanRow)) + : []; + const { t } = getServerTranslator(resolveLocale(locale)); const digest = buildDailyDigest( @@ -98,6 +173,7 @@ export async function loadDailyDigest( sleepLastSeenDaysAgo, syncIssues, preventiveDue, + coachPlans, }, t, ); @@ -109,6 +185,9 @@ export async function loadDailyDigest( daily_digest_sleep_pending: digest.sleepPending, daily_digest_item_count: digest.worthALook.length, daily_digest_has_score: digest.score !== null, + daily_digest_has_checkin: digest.worthALook.some( + (i) => i.kind === "coach_checkin", + ), }, });