diff --git a/src/lib/insights/__tests__/features.test.ts b/src/lib/insights/__tests__/features.test.ts index 3f5037b66..83f17d9cf 100644 --- a/src/lib/insights/__tests__/features.test.ts +++ b/src/lib/insights/__tests__/features.test.ts @@ -37,6 +37,7 @@ vi.mock("@/lib/medication-category", () => ({ })); import { prisma } from "@/lib/db"; +import { invalidateUserTimezone } from "@/lib/tz/resolver"; import { extractFeatures, FeaturesPayloadTooLargeError, @@ -69,6 +70,13 @@ const prismaMock = prisma as unknown as { const dayMs = 24 * 60 * 60 * 1000; +/** + * Its own account id, because `resolveUserTimezone` memoises per user for a + * minute — reusing "user-1" would either read a zone a neighbouring test set + * or leave one behind for the next. + */ +const TZ_USER = "user-tz"; + function rollupRow(daysAgo: number, mean: number, count: number) { return { bucketStart: new Date(Date.now() - daysAgo * dayMs), @@ -404,6 +412,44 @@ describe("extractFeatures — v1.22 preventive-care block", () => { const f = await extractFeatures("user-1", false); expect(f.preventiveCare).toBeUndefined(); }); + + /** + * The day counting itself is pinned in `preventive-care-days.test.ts`; what + * this pins is the wire between it and the account — that the zone the block + * counts on is the one on the person's PROFILE, and that it survives the trip + * through `extractFeatures`. Both ends were right once before while the + * assembly between them handed down something else. + * + * One instant, three answers. At 05:00 UTC on the 18th it is still the 17th + * in Los Angeles, so a checkup at 20:00 UTC on the 18th is tomorrow's there — + * while UTC and the Berlin server default both call it today's. Only the + * profile zone gives 1, so nothing but the profile zone can produce it. + */ + it("counts the days on the profile's calendar, not UTC's or the server's", async () => { + const NOW = Date.parse("2026-07-18T05:00:00Z"); + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(NOW); + invalidateUserTimezone(TZ_USER); + prismaMock.user.findUnique.mockResolvedValue({ + heightCm: 180, + dateOfBirth: new Date("1980-01-01"), + gender: "MALE", + timezone: "America/Los_Angeles", + }); + prismaMock.measurementReminder.findMany.mockResolvedValue([ + { label: "Augenarzt", nextDueAt: new Date("2026-07-18T20:00:00Z") }, + ]); + + try { + const f = await extractFeatures(TZ_USER, false); + expect(f.preventiveCare?.due).toEqual([ + { label: "Augenarzt", daysUntil: 1 }, + ]); + } finally { + vi.useRealTimers(); + invalidateUserTimezone(TZ_USER); + } + }); }); describe("extractFeatures — v1.22 workouts block", () => { diff --git a/src/lib/insights/__tests__/preventive-care-days.test.ts b/src/lib/insights/__tests__/preventive-care-days.test.ts new file mode 100644 index 000000000..dcee083ed --- /dev/null +++ b/src/lib/insights/__tests__/preventive-care-days.test.ts @@ -0,0 +1,174 @@ +/** + * The preventive-care block hands the briefing model two bare numbers per + * checkup — "due in N days", "overdue by N days" — and the model writes them + * into prose the person reads next to the checkup screens. So the number has + * to answer the same question the screens answer: how many dates away is it on + * the person's own calendar, not how many hours away divided by twenty-four. + * + * Every case below is one where those two answers differ. Each instant carries + * an explicit offset so the suite says the same thing whatever zone the machine + * running it is set to. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { measurementReminder: { findMany: vi.fn() } }, +})); + +import { prisma } from "@/lib/db"; +import { readPreventiveCareBlock } from "../feature-blocks"; + +const prismaMock = prisma as unknown as { + measurementReminder: { findMany: ReturnType }; +}; + +/** + * The profile zone under test. July is deliberate: no IANA zone shifts its + * clocks mid-July, so a fixed offset stands for the whole month and building + * 00:01 or 23:59 can never land on a skipped or repeated hour. + */ +const ZONE = "Europe/Berlin"; +const ZONE_OFFSET = "+02:00"; + +// Fri 17th, Sat 18th, Sun 19th of July 2026. +const FRIDAY = 17; +const SATURDAY = 18; +const SUNDAY = 19; + +const pad = (n: number) => String(n).padStart(2, "0"); + +/** A wall-clock time in {@link ZONE}, as the instant the column stores. */ +function zoned(day: number, hour: number, minute = 0): Date { + return new Date( + `2026-07-${pad(day)}T${pad(hour)}:${pad(minute)}:00${ZONE_OFFSET}`, + ); +} + +/** The same wall clock as the epoch millis the caller passes for `now`. */ +function at(day: number, hour: number, minute = 0): number { + return zoned(day, hour, minute).getTime(); +} + +/** One reminder due at the given instant, as the only row the read returns. */ +function onlyReminder(nextDueAt: Date) { + prismaMock.measurementReminder.findMany.mockResolvedValue([ + { label: "Augenarzt", nextDueAt }, + ]); +} + +beforeEach(() => { + prismaMock.measurementReminder.findMany.mockReset(); +}); + +describe("readPreventiveCareBlock counts calendar days, not hours", () => { + it("keeps a checkup booked for this morning on today's list all day", async () => { + // Eleven hours in the past, and still the checkup for today. An hour count + // moved it to the overdue list and then rounded the count to nothing, so + // the payload said "overdue by 0 days" about something not yet missed. + onlyReminder(zoned(FRIDAY, 9)); + const block = await readPreventiveCareBlock("user-1", at(FRIDAY, 20), ZONE); + + expect(block?.overdue).toEqual([]); + expect(block?.due).toEqual([{ label: "Augenarzt", daysUntil: 0 }]); + }); + + it("calls a Sunday 01:00 checkup two days out when read late on Friday", async () => { + // Twenty-six hours apart, so an hour count rounds to one and the briefing + // says tomorrow. Two dates separate them on the calendar. + onlyReminder(zoned(SUNDAY, 1)); + const block = await readPreventiveCareBlock("user-1", at(FRIDAY, 23), ZONE); + + expect(block?.due).toEqual([{ label: "Augenarzt", daysUntil: 2 }]); + }); + + it("calls a Saturday 23:59 checkup one day out when read just after Friday midnight", async () => { + // Just under forty-eight hours apart, so an hour count rounds to two. It is + // the very next date on the calendar. + onlyReminder(zoned(SATURDAY, 23, 59)); + const block = await readPreventiveCareBlock( + "user-1", + at(FRIDAY, 0, 1), + ZONE, + ); + + expect(block?.due).toEqual([{ label: "Augenarzt", daysUntil: 1 }]); + }); + + it("counts a checkup missed yesterday as one day overdue, however recent", async () => { + // Forty-five minutes in the past. It was still yesterday's checkup, and an + // hour count called it overdue by nothing at all. + onlyReminder(zoned(FRIDAY, 23, 30)); + const block = await readPreventiveCareBlock( + "user-1", + at(SATURDAY, 0, 15), + ZONE, + ); + + expect(block?.due).toEqual([]); + expect(block?.overdue).toEqual([{ label: "Augenarzt", daysOverdue: 1 }]); + }); + + it("keeps a whole clock-change day as one day", async () => { + // The Berlin day the clocks go back is twenty-five hours long, so an hour + // count rounds a same-day evening checkup up to tomorrow. + onlyReminder(new Date("2026-10-25T22:00:00Z")); // 23:00 Berlin, after + const block = await readPreventiveCareBlock( + "user-1", + Date.parse("2026-10-25T00:00:00Z"), // 02:00 Berlin, before + ZONE, + ); + + expect(block?.due).toEqual([{ label: "Augenarzt", daysUntil: 0 }]); + }); +}); + +describe("readPreventiveCareBlock counts on the profile's calendar", () => { + // 22:30 UTC on the 17th is already 00:30 on the 18th in Berlin, and the + // checkup at 21:00 UTC on the 18th is 23:00 Berlin on that same 18th. So + // Berlin says today and UTC says tomorrow about one identical pair of + // instants. Asserting BOTH is what makes this bite: an implementation that + // ignores the zone it is given returns the same number twice. + const NOW = Date.parse("2026-07-17T22:30:00Z"); + const DUE = new Date("2026-07-18T21:00:00Z"); + + it("gives two profiles either side of midnight two different counts", async () => { + onlyReminder(DUE); + const berlin = await readPreventiveCareBlock( + "user-1", + NOW, + "Europe/Berlin", + ); + onlyReminder(DUE); + const utc = await readPreventiveCareBlock("user-1", NOW, "UTC"); + + expect(berlin?.due).toEqual([{ label: "Augenarzt", daysUntil: 0 }]); + expect(utc?.due).toEqual([{ label: "Augenarzt", daysUntil: 1 }]); + }); + + /** + * The machine's own clock, moved under the function's feet. `vitest.config` + * pins the suite to UTC, and a `TZ=` prefix on the command line does not + * override that — the only way into another host zone is to assign + * `process.env.TZ`, which is what this does. Whatever the host is set to, + * the answer must not move: that is the difference between reading the + * profile and reading the server the code happens to run on. + */ + const HOST_ZONES = ["UTC", "Pacific/Auckland", "America/Los_Angeles"]; + + it("ignores the zone the host machine itself is set to", async () => { + const original = process.env.TZ; + const answers: Array = []; + try { + for (const hostZone of HOST_ZONES) { + process.env.TZ = hostZone; + onlyReminder(DUE); + const block = await readPreventiveCareBlock("user-1", NOW, ZONE); + answers.push(block?.due[0]?.daysUntil); + } + } finally { + process.env.TZ = original; + } + + expect(answers).toEqual([0, 0, 0]); + }); +}); diff --git a/src/lib/insights/feature-blocks.ts b/src/lib/insights/feature-blocks.ts index 191aef506..b8dc18b35 100644 --- a/src/lib/insights/feature-blocks.ts +++ b/src/lib/insights/feature-blocks.ts @@ -11,6 +11,7 @@ */ import { prisma } from "@/lib/db"; import { classifyReferenceRange } from "@/lib/labs/reference-range"; +import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day"; import { resolveLabFields } from "@/lib/labs/serialise"; import { readRollupBuckets } from "@/lib/rollups/measurement-rollups"; import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation"; @@ -251,10 +252,23 @@ export async function readLabsBriefingBlock( * v1.22 — preventive-care (Vorsorge) due + overdue read-side. Reads the * user's enabled, live reminders and buckets by the server-authoritative * `nextDueAt`. Returns `undefined` when nothing is due or overdue. + * + * `daysUntil` / `daysOverdue` are CALENDAR days in `timeZone`, counted by the + * same `calendarDaysUntil` the checkup screens phrase their due line with. + * They used to be an hour gap divided by 24, which is a different question: + * a checkup at 09:00 read that evening came out "overdue by 0 days", and one + * due tomorrow morning read late tonight came out due "in 0 days" — today's. + * The model narrates these numbers, so it repeated whichever the arithmetic + * had drifted to while the screen next to it said something else. + * + * `timeZone` is the user's profile zone; the horizon that bounds the read + * stays an hours-based cutoff on purpose — it is a query bound, not a + * statement to anyone about which day something falls on. */ export async function readPreventiveCareBlock( userId: string, now: number, + timeZone: string, ): Promise { const horizon = new Date(now + PREVENTIVE_DUE_HORIZON_DAYS * MS_PER_DAY); const rows = await prisma.measurementReminder.findMany({ @@ -273,15 +287,18 @@ export async function readPreventiveCareBlock( const overdue: NonNullable["overdue"] = []; const due: NonNullable["due"] = []; + const nowDate = new Date(now); for (const r of rows) { if (!r.nextDueAt) continue; const label = sanitizeLabel(r.label); if (!label) continue; - const diffMs = r.nextDueAt.getTime() - now; - if (diffMs < 0) { - overdue.push({ label, daysOverdue: Math.round(-diffMs / MS_PER_DAY) }); + const days = calendarDaysUntil(r.nextDueAt, nowDate, timeZone); + // Today's checkup is due, not overdue — the hour it was booked for may + // have passed, but the day it belongs to has not. + if (days < 0) { + overdue.push({ label, daysOverdue: -days }); } else { - due.push({ label, daysUntil: Math.round(diffMs / MS_PER_DAY) }); + due.push({ label, daysUntil: days }); } } if (overdue.length === 0 && due.length === 0) return undefined; diff --git a/src/lib/insights/features.ts b/src/lib/insights/features.ts index 916c05b45..7157651f5 100644 --- a/src/lib/insights/features.ts +++ b/src/lib/insights/features.ts @@ -1354,9 +1354,15 @@ export async function extractFeatures( (typeof sinceDays === "number" && sinceDays >= INTEGRATION_BLOCK_MIN_WINDOW_DAYS); if (includeIntegrations) { + // The preventive-care block states a distance in whole days, so it needs + // to know whose calendar it is counting on. Resolved here rather than + // inside the block because the resolver reads the database and this is + // the layer that already talks to it; the resolver's own 60 s cache makes + // the second call in this function free. + const preventiveTz = await resolveUserTimezone(userId); const [labs, preventiveCare, workouts, ecg] = await Promise.all([ readLabsBriefingBlock(userId, now), - readPreventiveCareBlock(userId, now), + readPreventiveCareBlock(userId, now, preventiveTz), readWorkoutsBlock(userId, now), // S10 — ECG device-verdict descriptor (never the waveform). Weaves the // ECG silo into the narrative; the model may reference it, attributed to diff --git a/src/lib/measurement-reminders/due-day.ts b/src/lib/measurement-reminders/due-day.ts index 855df0943..5efa96ed1 100644 --- a/src/lib/measurement-reminders/due-day.ts +++ b/src/lib/measurement-reminders/due-day.ts @@ -14,11 +14,16 @@ * `nextDueAt`) because this is the read side of the same value. Nothing in * this file touches the database or the network, so a client component can * import it directly. + * + * The words are only half of it. The AI features payload states the same + * distance as a bare number ("due in N days", "overdue by N days") for the + * briefing to narrate, and it counted hours the way the dashboard card used + * to. So the counting itself is {@link calendarDaysUntil} and both the words + * and the number come off it — the screens and the prose the model writes + * about them cannot say different things about the same reminder. */ import { DEFAULT_TIMEZONE, isValidTimezone } from "@/lib/tz/format"; -import { startOfLocalDayInTz } from "@/lib/tz/local-day"; - -const DAY_MS = 24 * 60 * 60 * 1000; +import { localDayIndex, wallClockInTz } from "@/lib/tz/wall-clock"; /** A fully-qualified i18n key plus the day count it interpolates. */ export interface RelativeDue { @@ -26,6 +31,39 @@ export interface RelativeDue { days: number; } +/** + * How many CALENDAR days forward `target` sits from `now`, both read on the + * wall clock of `timeZone`. Today is 0, tomorrow is 1, yesterday is -1. + * + * Not an hour count divided by 24. A checkup due at 09:00 is still due today + * at 20:00; one due tomorrow at 01:00 is tomorrow's whether it is read at + * 23:00 tonight or at 06:00 this morning. Dividing the gap by 24 h answers a + * different question and gets both of those wrong, and it also admits the + * 23 h / 25 h length of a clock-change day. This differences two integer day + * indices instead, so the answer is exact by construction rather than by + * rounding. + * + * `timeZone` is the person's PROFILE zone — the same clock the date printed + * beside the phrase is rendered in. It is required on purpose: it used to be + * implicit, so the day floor silently took the DEVICE clock while the date + * next to it took the profile clock, and on a laptop set elsewhere the card + * printed a date and a phrase that contradicted each other. A zone that + * cannot be read falls back exactly as `relativeCalendarDate` and `fmt.date` + * do (issue #490) — to Berlin, never to the host clock, because landing on + * the host is what let the two halves drift apart in the first place. + */ +export function calendarDaysUntil( + target: Date, + now: Date, + timeZone: string, +): number { + const zone = isValidTimezone(timeZone) ? timeZone : DEFAULT_TIMEZONE; + return ( + localDayIndex(wallClockInTz(target, zone)) - + localDayIndex(wallClockInTz(now, zone)) + ); +} + /** * v1.32.36 — returns a FULLY-QUALIFIED i18n key, not a bare tail. A call site * that interpolates the tail onto the namespace root anchors the @@ -33,14 +71,10 @@ export interface RelativeDue { * it reachable and turns the guard into a no-op for the whole namespace. The * guard now refuses that shape outright, so the key is built whole here. * - * `timeZone` is required on purpose. It used to be implicit — the day floor - * silently took the DEVICE clock while the date printed next to it took the - * profile clock, so on a laptop set to another zone the card could print a - * date and a phrase that contradicted each other. Naming the zone is how a - * caller says which of those two clocks it means, and there is no default - * because the missing answer was the whole bug: a silent caller got something - * plausible-looking rather than an error. Pass what the neighbouring date - * label renders in — `useDisplayTimezone()` on the client. + * Pass what the neighbouring date label renders in — `useDisplayTimezone()` + * on the client. The zone argument carries straight through to + * {@link calendarDaysUntil}, which is where the day counting happens and + * where the reasoning for both the requirement and the fallback lives. */ export function relativeDueKey( nextDueAt: string | null, @@ -51,20 +85,7 @@ export function relativeDueKey( const due = new Date(nextDueAt); if (Number.isNaN(due.getTime())) return { key: "measurementReminders.nextDue.none", days: 0 }; - // Resolve exactly as `relativeCalendarDate` and `fmt.date` do (issue #490): - // a usable zone stands, anything else lands on Berlin. Note where it does - // NOT land — the device. Falling back to the host clock is what let the two - // halves of this card drift apart, and a zone the profile mirror could not - // make sense of has to end up wherever the printed date ends up. - const zone = isValidTimezone(timeZone) ? timeZone : DEFAULT_TIMEZONE; - // v1.18.9 (recon) — compare CALENDAR-day deltas in the user's zone, not a - // rolling-24h delta. A reminder due at 09:00 viewed the same evening at - // 20:00 must still read "heute", and one whose due calendar-day is before - // today must read "überfällig" — a raw `(due - now) / DAY_MS` round would - // mis-bucket both. Floor each instant to its local day start first. - const dueDay = startOfLocalDayInTz(due, zone).getTime(); - const nowDay = startOfLocalDayInTz(new Date(now), zone).getTime(); - const deltaDays = Math.round((dueDay - nowDay) / DAY_MS); + const deltaDays = calendarDaysUntil(due, new Date(now), timeZone); if (deltaDays < 0) return { key: "measurementReminders.overdueByDays",