From 67d4f877204c54ffad00625c4e0e20c1e8516806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 1 Aug 2026 20:09:37 +0200 Subject: [PATCH] Hold the checkup overdue flag for the whole day it is due The MCP due-list answered "is this checkup overdue" by comparing the two instants, so a checkup booked for nine in the morning read overdue from ten past. The screens read the same reminder as "today" until midnight. One reminder, two verdicts, and the model reading the tool result repeated the one nobody could see. Same counting as the screens and the briefing payload now, on the account's own clock rather than the server's. A checkup stays due for the whole of the date it belongs to, and is overdue from the date after. --- src/lib/mcp/__tests__/tools.test.ts | 89 +++++++++++++++++++++++++++++ src/lib/mcp/tools.ts | 14 ++++- 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index 48e013be8..358b6ac1d 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -687,6 +687,95 @@ describe("get_preventive_care", () => { }; expect(result.present).toBe(false); }); + + /** + * Overdue is a question about dates, and the answer has to hold for the + * whole of the date. Comparing the two instants flipped the flag at the + * hour the checkup was booked for, so from ten past nine the tool told the + * model a checkup was overdue while the screen beside it still read + * "today" — one reminder, two verdicts. + * + * Read in the profile zone the mocked account carries (UTC), so the + * instants below say the same thing whatever the host machine is set to. + */ + async function overdueFlagFor( + nextDueAt: string, + now: string, + timezone = "UTC", + ) { + vi.setSystemTime(Date.parse(now)); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ timezone } as never); + vi.mocked(prisma.measurementReminder.findMany).mockResolvedValue([ + { id: "r-1" }, + ] as never); + vi.mocked(toMeasurementReminderDto).mockReturnValue({ + id: "r-1", + label: "Blood pressure check", + measurementType: "BLOOD_PRESSURE_SYS", + intervalDays: 30, + rrule: null, + anchorDate: null, + endsOn: null, + origin: "VORSORGE", + notifyHour: 9, + location: null, + nextDueAt, + lastSatisfiedAt: null, + enabled: true, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + const result = (await tool("get_preventive_care").run(CTX, {})) as { + checkups: Array<{ overdue: boolean }>; + }; + return result.checkups[0]?.overdue; + } + + it("holds the overdue flag for the whole of the day a checkup is due", async () => { + vi.useFakeTimers(); + try { + // The booked hour has passed by eleven hours, and it is still the day + // the checkup belongs to. + expect( + await overdueFlagFor( + "2026-07-17T09:00:00.000Z", + "2026-07-17T20:00:00.000Z", + ), + ).toBe(false); + // Forty-five minutes later on the clock, and a date later on the + // calendar. Now it is missed. + expect( + await overdueFlagFor( + "2026-07-17T23:30:00.000Z", + "2026-07-18T00:15:00.000Z", + ), + ).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("reads the dates off the profile's calendar, not the server's", async () => { + vi.useFakeTimers(); + try { + // The same pair of instants as the first case above, which UTC reads as + // one date and answers "not overdue". Twelve hours east it is already + // the next morning, so that same checkup was yesterday's and is missed. + // Only the profile zone can produce the second answer. + expect( + await overdueFlagFor( + "2026-07-17T09:00:00.000Z", + "2026-07-17T20:00:00.000Z", + "Pacific/Auckland", + ), + ).toBe(true); + } finally { + vi.useRealTimers(); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: "UTC", + } as never); + } + }); }); describe("search — cursor pagination", () => { diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 13abed92e..b0d9ee897 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -67,6 +67,7 @@ import { resolveSyncVerdict, } from "@/lib/integrations/sync-verdict"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; +import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day"; import { fenceUserText, scrubFenceMarkers } from "@/lib/ai/coach/data-fence"; import type { McpAuthContext } from "./auth"; @@ -1566,7 +1567,16 @@ export const MCP_TOOLS: McpToolDefinition[] = [ return { present: false }; } - const nowMs = Date.now(); + // Overdue is a CALENDAR-day verdict, the same one the checkups screen + // renders. Comparing the instants said a checkup booked for nine this + // morning was overdue by ten past, so the tool called something overdue + // on the very day it is due while the screen next to it read "today". + const user = await prisma.user.findUnique({ + where: { id: ctx.userId }, + select: { timezone: true }, + }); + const timezone = user?.timezone || DEFAULT_TIMEZONE; + const now = new Date(); const checkups = reminders.map((r) => { const dto = toMeasurementReminderDto(r); return { @@ -1575,7 +1585,7 @@ export const MCP_TOOLS: McpToolDefinition[] = [ nextDueAt: dto.nextDueAt, overdue: dto.nextDueAt !== null - ? new Date(dto.nextDueAt).getTime() < nowMs + ? calendarDaysUntil(new Date(dto.nextDueAt), now, timezone) < 0 : false, location: dto.location, lastSatisfiedAt: dto.lastSatisfiedAt,