feat(coach): close the plan check-in loop on the Today rail

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-16 22:30:46 +02:00
1 parent 7c96abc8ec
commit b7a6b4c898
15 files changed
+643 -32

No files matched your search

+25
View File
@@ -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,
+9 -1
View File
@@ -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",
+9 -1
View File
@@ -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",
+9 -1
View File
@@ -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",
+9 -1
View File
@@ -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é",
+9 -1
View File
@@ -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",
+9 -1
View File
@@ -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",
+18
View File
@@ -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.
@@ -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<string, unknown>;
};
expect(Object.keys(arg.data)).toEqual(["status"]);
});
@@ -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(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
<QueryClientProvider client={client}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
+26 -2
View File
@@ -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 <Link>, 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 }) {
</h2>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{digest.worthALook.map((item, i) => (
<PriorityCard key={`${item.kind}-${i}`} item={item} />
<PriorityCard
key={`${item.kind}-${i}`}
item={item}
onAction={handleAction}
/>
))}
</div>
</div>
+59
View File
@@ -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 `<Link>` — 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 };
}
+151
View File
@@ -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> = {}): DailyDigestInput {
sleepLastSeenDaysAgo: 0,
syncIssues: [],
preventiveDue: [],
coachPlans: [],
...over,
};
}
const DAY = 86_400_000;
function plan(over: Partial<DailyDigestCoachPlan> = {}): 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<typeof buildDailyDigest>) {
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");
});
});
+141 -1
View File
@@ -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);
+100 -21
View File
@@ -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<DailyDigest> {
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",
),
},
});