Merge work/today-day-arithmetic

This commit is contained in:
Marc-André Bombeck committed 2026-08-02 00:37:10 +02:00
commit 3b9504a441
7 files changed
+395 -32

No files matched your search

@@ -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", () => {
@@ -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<typeof vi.fn> };
};
/**
* 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<number | undefined> = [];
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]);
});
});
+21 -4
View File
@@ -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<AggregatedFeatures["preventiveCare"] | undefined> {
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<AggregatedFeatures["preventiveCare"]>["overdue"] =
[];
const due: NonNullable<AggregatedFeatures["preventiveCare"]>["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;
+7 -1
View File
@@ -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
+89
View File
@@ -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", () => {
+12 -2
View File
@@ -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,
+46 -25
View File
@@ -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",