mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +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
@@ -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"]);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user