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,