mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Evaluate the context cues the reminder grammar has promised since v1.22
A Coach reminder captured with NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED, NEXT_SLEEP_LOGGED or NEXT_APP_OPEN confirmed to the user and then never fired: the daily sweep skipped context triggers and no other evaluator existed. Build the evaluator. The three logging cues resolve on evidence — a live matching-type measurement logged after capture — and ride the same eventful reminder-satisfy hook every ingest path already fires, with the daily sweep re-running them as the idempotent backstop. NEXT_APP_OPEN resolves on the nudge-status read (the poll that mounts with the app chrome), owner-only so a delegate's visit never satisfies it, with a capture grace so the session that created the reminder does not count as the next open. Every trigger delivers through the surfacing transaction extracted from the sweep: conversation + assistant message + status flip, all-or-nothing.
This commit is contained in:
1 parent
0cec8b07bd
commit
cd127010fe
11 files changed
+764
-91
No files matched your search
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* GET /api/insights/coach/nudge-status — the NEXT_APP_OPEN evaluation
|
||||
* hook. This poll mounts with the app chrome, which makes it the
|
||||
* app-open signal, so the route runs the context evaluation before it
|
||||
* computes the unread flag (owner only — a delegate opening the record
|
||||
* is not the owner opening their app).
|
||||
*
|
||||
* Watched red: with the `evaluateCoachContextReminders` call removed
|
||||
* from the route the first test fails on the hook assertion — the
|
||||
* pre-fix state where NEXT_APP_OPEN reminders could never fire.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const { requireRecordAuth, evaluateContext, readStatus } = vi.hoisted(() => ({
|
||||
requireRecordAuth: vi.fn(),
|
||||
evaluateContext: vi.fn(async () => ({ surfaced: 0, errored: 0 })),
|
||||
readStatus: vi.fn(async () => ({ unread: false, nudgedAt: null })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireRecordAuth,
|
||||
}));
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
requireAssistantSurface: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/nudge-status", () => ({
|
||||
readCoachNudgeStatus: readStatus,
|
||||
}));
|
||||
vi.mock("@/lib/ai/coach/context-reminders", () => ({
|
||||
evaluateCoachContextReminders: evaluateContext,
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({ prisma: { __tag: "prisma" } }));
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiSuccess: (data: unknown) => ({ data, error: null }),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
readStatus.mockResolvedValue({ unread: false, nudgedAt: null });
|
||||
});
|
||||
|
||||
describe("nudge-status — NEXT_APP_OPEN hook", () => {
|
||||
it("runs the app-open evaluation for the owner BEFORE computing the unread flag", async () => {
|
||||
requireRecordAuth.mockResolvedValue({
|
||||
user: { id: "u1" },
|
||||
actor: { id: "u1" },
|
||||
});
|
||||
|
||||
await (GET as unknown as () => Promise<unknown>)();
|
||||
|
||||
expect(evaluateContext).toHaveBeenCalledTimes(1);
|
||||
const [prismaArg, userId, trigger] = evaluateContext.mock.calls[0]!;
|
||||
expect((prismaArg as { __tag: string }).__tag).toBe("prisma");
|
||||
expect(userId).toBe("u1");
|
||||
expect(trigger).toBe("app-open");
|
||||
// Ordering: the surfaced message must be visible to this very read.
|
||||
expect(evaluateContext.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
readStatus.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
});
|
||||
|
||||
it("never satisfies the cue for a switched caller", async () => {
|
||||
requireRecordAuth.mockResolvedValue({
|
||||
user: { id: "owner" },
|
||||
actor: { id: "delegate" },
|
||||
});
|
||||
|
||||
await (GET as unknown as () => Promise<unknown>)();
|
||||
|
||||
expect(evaluateContext).not.toHaveBeenCalled();
|
||||
expect(readStatus).toHaveBeenCalledWith("owner");
|
||||
});
|
||||
|
||||
it("keeps the badge alive when the evaluator fails", async () => {
|
||||
requireRecordAuth.mockResolvedValue({
|
||||
user: { id: "u1" },
|
||||
actor: { id: "u1" },
|
||||
});
|
||||
evaluateContext.mockRejectedValueOnce(new Error("evaluator down"));
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
const res = (await (GET as unknown as () => Promise<unknown>)()) as {
|
||||
error: null;
|
||||
};
|
||||
expect(res.error).toBeNull();
|
||||
expect(readStatus).toHaveBeenCalledWith("u1");
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,8 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status";
|
||||
import { evaluateCoachContextReminders } from "@/lib/ai/coach/context-reminders";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
// The RECORD's unread signal, not the caller's. The tempting reading is that
|
||||
@@ -43,11 +45,26 @@ export const GET = apiHandler(async () => {
|
||||
// switch and that is not in tension with this: chat spends the owner's AI
|
||||
// budget and writes into their conversation, and reading whether the thread
|
||||
// has something unopened does neither.
|
||||
const { user } = await requireRecordAuth("read", "record");
|
||||
const { user, actor } = await requireRecordAuth("read", "record");
|
||||
// Operator-level flag — an `AppSettings` singleton, unaffected by whose
|
||||
// record is open.
|
||||
await requireAssistantSurface("coach");
|
||||
|
||||
// NEXT_APP_OPEN Coach reminders resolve here: this poll mounts with the
|
||||
// app chrome, which makes it the app-open signal itself. Owner-only — a
|
||||
// delegate opening the record is not the owner opening their app, so a
|
||||
// switched caller never satisfies the cue. Awaited so the reminder's
|
||||
// message is already in the thread when this very response computes the
|
||||
// unread flag; fault-isolated so an evaluator hiccup can never break the
|
||||
// badge. Steady-state cost is one indexed query returning zero rows.
|
||||
if (actor.id === user.id) {
|
||||
try {
|
||||
await evaluateCoachContextReminders(prisma, user.id, "app-open");
|
||||
} catch (err) {
|
||||
console.warn("[coach] app-open context evaluation failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Shared with the `/coach` RSC prefetch (`src/app/coach/page.tsx`) so both
|
||||
// readers compute the unread signal identically.
|
||||
return apiSuccess(await readCoachNudgeStatus(user.id));
|
||||
|
||||
Reference in new issue
Block a user