mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
7c96abc8ec
commit
b7a6b4c898
15 files changed
+643
-32
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user