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

+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"]);
});