From 49ac6679c856b745ccda246065eae7765e0409da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 1 Aug 2026 20:04:10 +0200 Subject: [PATCH 1/6] Count preventive-care days on the person's calendar, not in hours MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The briefing payload states how far off a checkup is as a bare number the model narrates back: "due in N days", "overdue by N days". It got that number by dividing the hour gap by twenty-four, which answers a different question than the one the sentence asks. A checkup booked for nine this morning came out "overdue by 0 days" by the evening. One due Sunday at 01:00, read late on Friday, came out one day off — Saturday, on a calendar where it is two. A clock-change day is twenty-three or twenty-five hours long and rounded whichever way its length pushed it. The checkup screens had exactly this and it was fixed there, so the counting already exists. Lift it out as `calendarDaysUntil` and have both the phrase and the number come off it, rather than leaving the payload with a second body that can drift again. It counts by differencing two integer day indices read on the person's profile clock, so the answer is exact rather than rounded, and `extractFeatures` resolves that zone and hands it down — the number now agrees with the screen the person is looking at. Today's checkup moves off the overdue list, where the hour gap had put it as soon as its appointment time passed. The hour it was booked for may be behind, but the day it belongs to is not. The horizon that bounds the read stays an hour cutoff on purpose. It is a query bound, not a statement to anyone about which day something falls on. --- src/lib/insights/__tests__/features.test.ts | 46 +++++ .../__tests__/preventive-care-days.test.ts | 174 ++++++++++++++++++ src/lib/insights/feature-blocks.ts | 25 ++- src/lib/insights/features.ts | 8 +- src/lib/measurement-reminders/due-day.ts | 71 ++++--- 5 files changed, 294 insertions(+), 30 deletions(-) create mode 100644 src/lib/insights/__tests__/preventive-care-days.test.ts 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", 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 2/6] 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, From a08631e0a1fd388df58786efb9134b676231036d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 1 Aug 2026 22:39:50 +0200 Subject: [PATCH 3/6] Freeze the set of surfaces that resolve a caller by any mechanism The Bearer guard freezes one mechanism: the files that turn an Authorization header into a user. It reads like coverage of "who can resolve a caller" and it is not. Two other families do the same job by other means and no guard watches either. Fifteen files under src/app read the session cookie directly instead of going through requireAuth: eight API routes and seven pages. Nine more authenticate on a token carried in the URL, including the clinician share tree, which serves one person's whole health record to an anonymous caller holding a path segment. A sweep that greps for getSession sees none of the second family. Freeze both as literal lists with a reason per entry, matched by five independent legs: the session-deriving helpers, a direct read of the session cookie name, a dynamic route segment carrying a credential, share-token resolution, and a query-string credential. Legs overlap, so each asserts its own membership and its own non-zero count. A leg whose matcher stops matching then fails instead of letting the union carry it. --- src/__tests__/session-surface-guard.test.ts | 314 ++++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 src/__tests__/session-surface-guard.test.ts diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts new file mode 100644 index 000000000..067bd97d3 --- /dev/null +++ b/src/__tests__/session-surface-guard.test.ts @@ -0,0 +1,314 @@ +/** + * Structural guards on the surfaces that resolve a caller's identity. + * + * `bearer-scope-enforcement-guard.test.ts` freezes ONE mechanism: the files + * that turn an `Authorization: Bearer` value into a user. That guard is + * correct and it holds. What it is not is coverage of "who can resolve a + * caller". Two other families do the same job by other means, and a sweep + * written by copying the Bearer guard sees neither of them: + * + * A. A route or page that reads the session cookie itself, instead of + * going through `requireAuth()` inside `apiHandler`. `requireAuth` is + * where the fail-closed scope arm, the audit row, and the admin + * boundary live; a direct `getSession()` has none of them and answers + * to nothing but the file it sits in. + * B. A surface whose whole credential is a token in the URL — the + * clinician share tree serves one person's complete health record to + * an anonymous caller holding an `hls_` path segment, and never + * touches a session at all. No amount of grepping for `getSession` + * will ever show it to you. + * + * Neither family is wrong. Both are deliberate, and both are documented at + * their own call sites. The defect this file closes is that nothing said so + * when a new member joined. + * + * These are tripwires, not proofs. They cannot show an allowlist is + * correct — only that it has not changed without someone editing this file. + * + * A note on why every matcher carries its own count assertion: several legs + * below overlap (four of the five share-token files also sit under a + * `[token]` segment). If one leg's regex silently stopped matching, the + * union would be unchanged and the guard would stay green for the wrong + * reason. Each leg therefore asserts its own membership, so a matcher that + * matches nothing fails instead of passing. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join, sep } from "node:path"; +import { globSync } from "node:fs"; + +const SRC = join(process.cwd(), "src"); + +/** + * Every non-test `.ts` / `.tsx` under `src/app/`, which is the whole of the + * request-handling tree: API routes, RSC pages, layouts, route handlers. + * The generated Prisma client is excluded by construction (it is not under + * `app/`) and never read — see CLAUDE.md. + */ +function appFiles(): string[] { + return globSync("app/**/*.{ts,tsx}", { cwd: SRC }) + .map((p) => p.split(sep).join("/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .sort(); +} + +function read(rel: string): string { + return readFileSync(join(SRC, rel), "utf8"); +} + +function appFilesMatching(re: RegExp): string[] { + return appFiles().filter((rel) => re.test(read(rel))); +} + +describe("S1 — the direct session-resolution set is frozen", () => { + /** + * Files under `src/app` that resolve the caller from the session cookie + * WITHOUT going through `requireAuth()`. Every other authenticated route + * in the tree — 300-odd of them — reaches its user through `apiHandler` + * plus `requireAuth`, which is what makes this list short enough to read. + * + * An entry here is not a finding. It is a place where the protections + * `requireAuth` applies centrally have to be argued locally, so the + * reason is the point of the entry. + */ + const SESSION_RESOLUTION_ALLOWLIST: Record = { + // Completes the native ASWebAuthenticationSession handoff. Validates the + // browser session itself because it also needs `session.createdAt` for + // the freshness comparison, which `requireAuth` does not project. + "app/api/auth/native/complete/route.ts": + "reads createdAt for the handoff freshness check", + // Anonymous IdP callback. Probes for an already-valid session so a + // replayed callback redirects instead of minting a second identity. + "app/api/auth/oidc/callback/route.ts": + "duplicate-callback probe on an otherwise anonymous route", + // Public liveness probe. The session read only decides whether the body + // carries operational detail; the status code is computed without it. + "app/api/health/route.ts": "admin-only detail on a public probe", + // OAuth authorize runs under `withBackgroundEvent`, not `apiHandler`, + // because it owes RFC-format error bodies — so it resolves the + // consenting human itself. + "app/api/mcp/oauth/authorize/route.ts": + "consent decision on a route outside apiHandler", + // Provider OAuth callbacks. The browser arrives as a top-level + // navigation, so the code is bound to the session user and redirected. + "app/api/oura/callback/route.ts": + "binds the OAuth code to the session user", + "app/api/polar/callback/route.ts": + "binds the OAuth code to the session user", + "app/api/strava/callback/route.ts": + "binds the OAuth code to the session user", + "app/api/whoop/callback/route.ts": + "binds the OAuth code to the session user", + // RSC prefetch: the server component hydrates the client query cache + // with the session user's own data. Absence of a session skips the + // prefetch rather than failing the render. + "app/coach/page.tsx": "RSC prefetch of the session user's coach state", + "app/insights/page.tsx": "RSC prefetch of the session user's insights", + "app/insights/workouts/page.tsx": + "RSC prefetch of the session user's workouts", + "app/medications/page.tsx": + "RSC prefetch of the session user's medications", + "app/page.tsx": "RSC prefetch of the session user's dashboard snapshot", + // Onboarding routing. Reads the actor's own onboarding step to redirect; + // renders no record data. + "app/onboarding/[step]/page.tsx": + "redirects on the actor's onboarding step", + "app/onboarding/page.tsx": "redirects on the actor's onboarding step", + }; + + /** + * The identity-deriving helpers exported by `lib/auth/session.ts`. The + * lifecycle exports (`createSession`, `destroySession`, `destroyAllSessions`, + * `destroySessionById`, `destroyOtherSessions`) are deliberately NOT here: + * they act on a session, they do not hand the handler an identity to scope + * a query by. Adding a sixth deriving helper to `session.ts` means adding + * it to this regex. + */ + const SESSION_DERIVING_HELPERS = + /\b(getSession|getSessionUserLocale|validateSessionFromCookieValue|validateSessionWithCreatedAt)\s*\(/; + + /** + * Reaching for the cookie by name is the way around the helpers. Both the + * exported constant and the raw literal count. + */ + const RAW_SESSION_COOKIE = /\bSESSION_COOKIE_NAME\b|"healthlog_session"/; + + it("no file under src/app derives a session outside requireAuth unlisted", () => { + const resolvers = appFilesMatching(SESSION_DERIVING_HELPERS); + + // Non-zero proof. If the helper names are ever renamed out from under + // this regex, the match set collapses to empty — which must fail here + // rather than quietly agree with an emptied allowlist. + expect(resolvers.length).toBeGreaterThan(0); + expect(resolvers).toEqual(Object.keys(SESSION_RESOLUTION_ALLOWLIST).sort()); + }); + + it("no file under src/app reads the session cookie by name unlisted", () => { + const cookieReaders = appFilesMatching(RAW_SESSION_COOKIE); + + // Same non-zero proof, for this leg's own matcher. These two files also + // appear in the leg above, which is exactly why this count is asserted + // separately: an overlapping leg cannot borrow another leg's evidence. + expect(cookieReaders.length).toBeGreaterThan(0); + expect(cookieReaders).toEqual([ + "app/api/auth/native/complete/route.ts", + "app/api/auth/oidc/callback/route.ts", + ]); + + // And whatever it finds must already be an argued session surface. + for (const rel of cookieReaders) { + expect(Object.keys(SESSION_RESOLUTION_ALLOWLIST)).toContain(rel); + } + }); + + it("every allowlist entry carries a reason", () => { + for (const [rel, reason] of Object.entries(SESSION_RESOLUTION_ALLOWLIST)) { + expect(reason.trim().length, `${rel} has no reason`).toBeGreaterThan(0); + } + }); +}); + +describe("S2 — the URL-token authentication set is frozen", () => { + /** + * Files whose credential is a token in the URL — a path segment or a + * query parameter — rather than a session cookie or a Bearer header. + * + * This is the family the Bearer guard cannot see and a `getSession` sweep + * cannot see. `src/app/c/[token]/**` resolves one user's entire health + * record from an `hls_` path segment held by an anonymous caller. That is + * the product's design, it predates every guard in this file, and it is + * not a defect. What was a defect is that a sixth door could have been + * cut next to it without anything saying so. + */ + const URL_TOKEN_ALLOWLIST: Record = { + // Clinician share link: the `hls_` path segment is the whole credential. + "app/c/[token]/page.tsx": "serves the shared record view", + "app/c/[token]/d/[id]/route.ts": "serves a shared document's bytes", + "app/c/[token]/fhir/route.ts": "serves the shared record as a FHIR bundle", + "app/c/[token]/report.pdf/route.ts": "serves the shared record as a PDF", + // The passphrase gate for the same token. Anonymous by design; mints the + // token-scoped unlock cookie the view above checks. + "app/api/c/[token]/unlock/route.ts": "verifies the share-link passphrase", + // Webhook entrypoints. The provider supports no headers on a delivery, + // so the shared secret rides the path segment and is scrubbed from logs + // by `PATH_SECRET_PATHS`. These resolve a record from the payload after + // the secret compares equal. + "app/api/whoop/webhook/[token]/route.ts": + "path-segment shared secret, plus an HMAC body signature", + "app/api/withings/webhook/[token]/route.ts": + "path-segment shared secret; no body signature is available", + // The legacy form of the same Withings secret, still alive for + // subscriptions that have not rotated to the path-segment URL. + "app/api/withings/webhook/route.ts": + "legacy query-parameter form of the Withings webhook secret", + // Shape check only. Validates the `hlv_` prefix and redirects; touches + // no database, resolves nothing, and is not an enumeration oracle. + // Listed because it is a URL-token surface, with the reason being that + // it authenticates nothing. + "app/invite/[token]/page.tsx": + "validates the invite token's shape and redirects; no lookup", + }; + + /** + * Leg 1 — route shape. A dynamic segment whose name carries `token` or + * `secret` puts a credential in the URL by construction. This matcher + * reads the filesystem, not the file, so no variable name can slip past + * it; a new such route is caught before anyone reads its body. + */ + function urlCredentialSegmentFiles(): string[] { + return appFiles().filter((rel) => + rel.split("/").some((seg) => /^\[.*(token|secret).*\]$/i.test(seg)), + ); + } + + /** + * Leg 2 — share-token resolution by content, so a share surface that ever + * moves out from under a `[token]` segment is still caught. + */ + const SHARE_TOKEN_RESOLVERS = + /\b(resolveShareToken|resolveShareGateState|resolveShareReportDownload|loadShareViewData)\s*\(/; + + /** + * Leg 3 — a credential read out of the query string. + */ + const QUERY_CREDENTIAL = + /searchParams\.get\(\s*["'](secret|token|key|passphrase)["']\s*\)/; + + it("every URL-credential route segment is named", () => { + const bySegment = urlCredentialSegmentFiles(); + + expect(bySegment.length).toBeGreaterThan(0); + expect(bySegment).toEqual([ + "app/api/c/[token]/unlock/route.ts", + "app/api/whoop/webhook/[token]/route.ts", + "app/api/withings/webhook/[token]/route.ts", + "app/c/[token]/d/[id]/route.ts", + "app/c/[token]/fhir/route.ts", + "app/c/[token]/page.tsx", + "app/c/[token]/report.pdf/route.ts", + "app/invite/[token]/page.tsx", + ]); + }); + + it("every share-token resolver is named", () => { + const byResolver = appFilesMatching(SHARE_TOKEN_RESOLVERS); + + expect(byResolver.length).toBeGreaterThan(0); + expect(byResolver).toEqual([ + "app/api/c/[token]/unlock/route.ts", + "app/c/[token]/d/[id]/route.ts", + "app/c/[token]/fhir/route.ts", + "app/c/[token]/page.tsx", + "app/c/[token]/report.pdf/route.ts", + ]); + }); + + it("every query-string credential read is named", () => { + const byQuery = appFilesMatching(QUERY_CREDENTIAL); + + expect(byQuery.length).toBeGreaterThan(0); + expect(byQuery).toEqual(["app/api/withings/webhook/route.ts"]); + }); + + it("the three legs together equal the frozen allowlist", () => { + const union = [ + ...new Set([ + ...urlCredentialSegmentFiles(), + ...appFilesMatching(SHARE_TOKEN_RESOLVERS), + ...appFilesMatching(QUERY_CREDENTIAL), + ]), + ].sort(); + + expect(union.length).toBeGreaterThan(0); + expect(union).toEqual(Object.keys(URL_TOKEN_ALLOWLIST).sort()); + }); + + it("every allowlist entry carries a reason", () => { + for (const [rel, reason] of Object.entries(URL_TOKEN_ALLOWLIST)) { + expect(reason.trim().length, `${rel} has no reason`).toBeGreaterThan(0); + } + }); +}); + +describe("S3 — the two families stay distinct", () => { + /** + * A file that both resolves a session AND authenticates by a URL token is + * not forbidden, but it is a shape nobody has reviewed. If one appears, + * it should appear in a diff that says so rather than in production. + */ + it("no file authenticates by both a session and a URL token", () => { + const sessionFiles = appFilesMatching( + /\b(getSession|getSessionUserLocale|validateSessionFromCookieValue|validateSessionWithCreatedAt)\s*\(/, + ); + const tokenFiles = appFiles().filter((rel) => + rel.split("/").some((seg) => /^\[.*(token|secret).*\]$/i.test(seg)), + ); + + expect(sessionFiles.length).toBeGreaterThan(0); + expect(tokenFiles.length).toBeGreaterThan(0); + + const both = sessionFiles.filter((rel) => tokenFiles.includes(rel)); + expect(both).toEqual([]); + }); +}); From da85e64cfad012c046075cacd129d132d206a80b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 1 Aug 2026 22:45:04 +0200 Subject: [PATCH 4/6] Catch the Bearer resolver the guard's matcher walked past The Bearer guard demanded the literal `apiToken.findUnique(`. `lib/idempotency.ts` writes the same call fluent-style, with a newline between `prisma.apiToken` and `.findUnique({`, so the matcher never saw it. It has been resolving a raw Bearer value to a user id since the idempotency work landed, outside the frozen set, while the guard stayed green on five files. Tolerate whitespace around the member access and name the file with what it does: it decides which user's idempotency cell a replay lands in, and authorises nothing, because the handler still runs its own requireAuth. Add the non-zero count assertion the other guards now carry. Narrowing the regex back to its old form turns this test red, which is the proof the broadened matcher is what finds the file. The mint-site matcher in T3 was checked for the same defect and has no hidden members. --- .../bearer-scope-enforcement-guard.test.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/__tests__/bearer-scope-enforcement-guard.test.ts b/src/__tests__/bearer-scope-enforcement-guard.test.ts index 21c938fdc..b1c721cbb 100644 --- a/src/__tests__/bearer-scope-enforcement-guard.test.ts +++ b/src/__tests__/bearer-scope-enforcement-guard.test.ts @@ -64,16 +64,34 @@ describe("T1 — the Bearer resolution set is frozen", () => { // surface, which gates on BOTH `medication:ingest` and the per-medication // `medication::ingest` grant and never touches `requireAuth`. "app/api/ingest/medication/route.ts", + // `defaultUserIdResolver` — resolves the Bearer token to a user id so an + // idempotency key files under its owner. It authorises nothing: the + // handler still runs its own `requireAuth()`, and this resolver only + // decides which user's cache cell a replay lands in. Narrow in reach and + // deliberate, but it is a raw Bearer value becoming a user id, so it + // belongs on this list rather than beside it. + "lib/idempotency.ts", ].sort(); it("no file outside the allowlist resolves a Bearer token to a user", () => { // `resolveBearerToken` is the shared primitive; a `tokenHash` lookup is the // hand-rolled equivalent. Either one turns a bearer credential into an // identity, so both have to stay inside the allowlist. + // + // The whitespace classes around `.findUnique` are load-bearing. The + // original matcher demanded the literal `apiToken.findUnique(`, and + // `lib/idempotency.ts` writes the call fluent-style — `prisma.apiToken`, + // newline, `.findUnique({`. It resolved a Bearer token to a user id for + // three releases without this guard ever seeing it. A matcher that misses + // a real resolver is green because it matched nothing, not because + // nothing was there. const resolvers = filesMatching( - /resolveBearerToken|apiToken\.findUnique\([\s\S]*?where:\s*\{\s*tokenHash/, + /resolveBearerToken|apiToken\s*\.\s*findUnique\(\s*\{[\s\S]*?where:\s*\{\s*tokenHash/, ); + // Non-zero proof: an empty match set must fail rather than agree with an + // emptied allowlist. + expect(resolvers.length).toBeGreaterThan(0); expect(resolvers).toEqual(RESOLUTION_ALLOWLIST); }); From af35346687084d2595bfcc9730cf59d79e4d4742 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 2 Aug 2026 00:15:29 +0200 Subject: [PATCH 5/6] Derive score-version fixtures from the SCORE_VERSION constant Eight test and fixture files hardcoded scoreVersion: 2, a value the constant has not held since v1.35.0. Now they import SCORE_VERSION and use it, so a future bump cannot leave a fixture pinned to a stale number. The one genuinely historical case (record.test.ts, proving the input fingerprint moves when the algorithm version moves) now derives both its "previous" and "current" markers from SCORE_VERSION instead of two unrelated literals, so the pair keeps its meaning after the next bump. --- e2e/settings-score.spec.ts | 11 ++++++++--- e2e/utils/mock-populated-insights.ts | 5 +++-- e2e/v1341-ui-evidence.spec.ts | 3 ++- .../[id]/restore/__tests__/round-trip.test.ts | 6 ++++-- src/app/api/daily/digest/__tests__/route.test.ts | 3 ++- src/components/daily/__tests__/today-hero.test.tsx | 5 +++-- src/lib/analytics/score/__tests__/record.test.ts | 12 ++++++++---- src/lib/dashboard/__tests__/snapshot.test.ts | 9 +++++---- 8 files changed, 35 insertions(+), 19 deletions(-) diff --git a/e2e/settings-score.spec.ts b/e2e/settings-score.spec.ts index 9909ead1f..399bd9f2e 100644 --- a/e2e/settings-score.spec.ts +++ b/e2e/settings-score.spec.ts @@ -2,6 +2,8 @@ import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { settleBeforeMeasure } from "./utils/settle"; +import { SCORE_VERSION } from "@/lib/analytics/score/types"; +import { healthScoreAlgorithmItemKey } from "@/lib/daily/priority-item-key"; /** * Settings → Health Score: the surface that decides which pillars count. @@ -65,11 +67,14 @@ function scoreReport() { ], delta: null, deltaReason: "no_current_score", - scoreVersion: 2, + scoreVersion: SCORE_VERSION, weightGoal: insufficient("no_goal"), // Raised and never dismissed, which is the state every account is in: // nothing has ever rendered this notice. - algorithmNotice: { itemKey: "health_score_algorithm:2", dismissed: false }, + algorithmNotice: { + itemKey: healthScoreAlgorithmItemKey(SCORE_VERSION), + dismissed: false, + }, }; } @@ -223,7 +228,7 @@ test.describe("Settings → Health Score", () => { await notice.locator('[data-slot="score-change-notice-dismiss"]').click(); await expect(notice).toHaveCount(0); - expect(dismissed).toBe("health_score_algorithm:2"); + expect(dismissed).toBe(healthScoreAlgorithmItemKey(SCORE_VERSION)); }); test("refuses a selection too narrow to produce a score, in plain language", async ({ diff --git a/e2e/utils/mock-populated-insights.ts b/e2e/utils/mock-populated-insights.ts index 266efdc2d..f54147906 100644 --- a/e2e/utils/mock-populated-insights.ts +++ b/e2e/utils/mock-populated-insights.ts @@ -4,6 +4,7 @@ import { SECTION_MOBILITY, SECTION_VITALS, } from "@/components/insights/derived/use-dashboard-derived"; +import { SCORE_VERSION } from "@/lib/analytics/score/types"; /** * A POPULATED Insights overview fixture. @@ -170,7 +171,7 @@ const HEALTH_SCORE = { composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP", "ADIPOSITY"], configured: false, noiseFloor: 3, - scoreVersion: 2, + scoreVersion: SCORE_VERSION, }, coverage: coverage(), confidence: confidence(), @@ -187,7 +188,7 @@ const HEALTH_SCORE = { ], delta: 2, deltaReason: null, - scoreVersion: 2, + scoreVersion: SCORE_VERSION, weightGoal: { status: "ok" as const, value: { diff --git a/e2e/v1341-ui-evidence.spec.ts b/e2e/v1341-ui-evidence.spec.ts index 6eea31bf5..6448d3472 100644 --- a/e2e/v1341-ui-evidence.spec.ts +++ b/e2e/v1341-ui-evidence.spec.ts @@ -2,6 +2,7 @@ import { mkdir } from "node:fs/promises"; import { join } from "node:path"; import type { Page, TestInfo } from "@playwright/test"; import type { DailyDigest } from "@/lib/daily/digest"; +import { SCORE_VERSION } from "@/lib/analytics/score/types"; import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; @@ -23,7 +24,7 @@ const NARRATIVE_DIGEST: DailyDigest = { band: "green", delta: -2, deltaReason: null, - scoreVersion: 2, + scoreVersion: SCORE_VERSION, composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], }, topSignal: LONG_HEADLINE_BRIEFING.signalsOfDay?.[0] ?? null, diff --git a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts index 1305d3123..88bc30426 100644 --- a/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts +++ b/src/app/api/admin/backups/[id]/restore/__tests__/round-trip.test.ts @@ -22,6 +22,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; import { Buffer } from "node:buffer"; +import { SCORE_VERSION } from "@/lib/analytics/score/types"; + process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -269,7 +271,7 @@ function sourceClient() { timezone: "Europe/Berlin", composite: 76, band: "yellow", - scoreVersion: 2, + scoreVersion: SCORE_VERSION, composition: ["BLOOD_PRESSURE", "SLEEP", "ADIPOSITY"], pillarScores: { BLOOD_PRESSURE: 86, SLEEP: 100, ADIPOSITY: 42 }, inputFingerprint: "a".repeat(64), @@ -629,7 +631,7 @@ describe("backup round trip — export, wire schema, restore", () => { timezone: "Europe/Berlin", composite: 76, band: "yellow", - scoreVersion: 2, + scoreVersion: SCORE_VERSION, inputFingerprint: "a".repeat(64), }); // The composition and the per-pillar scores are what keep the stored day diff --git a/src/app/api/daily/digest/__tests__/route.test.ts b/src/app/api/daily/digest/__tests__/route.test.ts index 28ca64f05..4ae9ddb1a 100644 --- a/src/app/api/daily/digest/__tests__/route.test.ts +++ b/src/app/api/daily/digest/__tests__/route.test.ts @@ -3,6 +3,7 @@ import { NextRequest } from "next/server"; import { apiError } from "@/lib/api-response"; import type { DailyDigest } from "@/lib/daily/digest"; +import { SCORE_VERSION } from "@/lib/analytics/score/types"; /** * `GET /api/daily/digest` — the P3 read seam. @@ -63,7 +64,7 @@ const DIGEST: DailyDigest = { band: "good", delta: 3, deltaReason: null, - scoreVersion: 2, + scoreVersion: SCORE_VERSION, composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], }, topSignal: null, diff --git a/src/components/daily/__tests__/today-hero.test.tsx b/src/components/daily/__tests__/today-hero.test.tsx index 00ca9ccc4..5be773a7e 100644 --- a/src/components/daily/__tests__/today-hero.test.tsx +++ b/src/components/daily/__tests__/today-hero.test.tsx @@ -6,6 +6,7 @@ import { I18nProvider } from "@/lib/i18n/context"; import { TodayHero } from "../today-hero"; import type { DailyDigest } from "@/lib/daily/digest"; import type { PriorityItem } from "@/lib/daily/priority-item"; +import { SCORE_VERSION } from "@/lib/analytics/score/types"; // The hero now wires the coach check-in card's keep / let-go taps through // `useCoachCheckinAction`, so it needs a QueryClient in the tree. @@ -57,7 +58,7 @@ function digest(over: Partial = {}): DailyDigest { band: "green", delta: 3, deltaReason: null, - scoreVersion: 2, + scoreVersion: SCORE_VERSION, composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], }, topSignal: { @@ -114,7 +115,7 @@ describe("", () => { band: "green", delta: 20, deltaReason: "algorithm_changed", - scoreVersion: 2, + scoreVersion: SCORE_VERSION, composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], }, })} diff --git a/src/lib/analytics/score/__tests__/record.test.ts b/src/lib/analytics/score/__tests__/record.test.ts index 2d92f5a91..b0b05c480 100644 --- a/src/lib/analytics/score/__tests__/record.test.ts +++ b/src/lib/analytics/score/__tests__/record.test.ts @@ -144,8 +144,12 @@ describe("stored bands", () => { }); describe("input fingerprint", () => { + // The fixture only needs two DIFFERENT version markers to prove the + // fingerprint is sensitive to a version move — pinned relative to + // SCORE_VERSION so the pair stays "previous vs current" rather than + // going stale (or colliding) the next time the constant bumps. const base = { - scoreVersion: 1, + scoreVersion: SCORE_VERSION - 1, composition: ["BLOOD_PRESSURE", "SLEEP", "ADIPOSITY"] as ScorePillarId[], pillars: HEALTHY, }; @@ -157,9 +161,9 @@ describe("input fingerprint", () => { }); it("moves when the algorithm version moves", () => { - expect(healthScoreInputFingerprint({ ...base, scoreVersion: 2 })).not.toBe( - healthScoreInputFingerprint(base), - ); + expect( + healthScoreInputFingerprint({ ...base, scoreVersion: SCORE_VERSION }), + ).not.toBe(healthScoreInputFingerprint(base)); }); it("moves when the composition narrows", () => { diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index 8077c2b43..fb6dacf08 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -77,7 +77,7 @@ vi.mock("@/lib/analytics/score/reader", () => ({ bandSetter: "BLOOD_PRESSURE" as const, composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"] as const, noiseFloor: 2, - scoreVersion: 2 as const, + scoreVersion: SCORE_VERSION, }, coverage, confidence: { score: 100, band: "high" as const }, @@ -94,7 +94,7 @@ vi.mock("@/lib/analytics/score/reader", () => ({ pillars: [], delta: legacy?.delta ?? null, deltaReason: null, - scoreVersion: 2 as const, + scoreVersion: SCORE_VERSION, weightGoal: { status: "insufficient" as const, coverage, @@ -154,6 +154,7 @@ import { } from "../snapshot"; import type { ModuleKey } from "@/lib/modules/gate"; import { MODULE_KEYS } from "@/lib/modules/registry"; +import { SCORE_VERSION } from "@/lib/analytics/score/types"; const emptySummary = { count: 0, @@ -370,7 +371,7 @@ describe("buildDashboardSnapshot — per-type thick-phase gate", () => { confidence: { score: 100, band: "high" }, composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], deltaReason: null, - scoreVersion: 2, + scoreVersion: SCORE_VERSION, bandSetter: "BLOOD_PRESSURE", // v1.21.2 (A5 / A6) — additive narrative fields, null absent a // disagreeing readiness set / a returned metric (the fake derived @@ -516,7 +517,7 @@ describe("buildDashboardSnapshot — healthScore (warm phase only)", () => { confidence: { score: 100, band: "high" }, composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], deltaReason: null, - scoreVersion: 2, + scoreVersion: SCORE_VERSION, bandSetter: "BLOOD_PRESSURE", // v1.21.2 (A5 / A6) — additive narrative fields, null here. tension: null, From 2618a7ae711ddda3331ce39d5a0ad2293055ff8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 2 Aug 2026 00:38:01 +0200 Subject: [PATCH 6/6] =?UTF-8?q?chore(release):=20v1.35.4=20=E2=80=94=20the?= =?UTF-8?q?=20checks=20tell=20the=20truth=20about=20the=20calendar=20and?= =?UTF-8?q?=20about=20themselves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 469b3b706..4aeedec14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ ## [Unreleased] +## [1.35.4] — 2026-08-02 + +### Fixed + +- A checkup due later today is no longer described as overdue. Two places + counted the distance to a checkup in hours and rounded, which is a different + question from how many calendar days away it is. On a Friday evening, + something due Saturday morning is fourteen hours away and something due Sunday + morning is thirty-eight, and rounding turned both into tomorrow. Both now + count calendar days in the timezone on your profile. +- The assistant and the screen beside it now agree about when a checkup is due. + The list the assistant reads flipped to overdue the moment the clock time + passed, while the page still said today until midnight. One appointment could + produce two different answers depending on which one you asked. + +### Internal + +- The set of places that can work out who is calling is now frozen by a check. + It covers surfaces that read a session and surfaces that authenticate by a + credential in the address rather than a session, which no earlier check + watched at all. Adding one now fails the check until it is listed with a + reason. +- One place that turns an access token into an account was outside the previous + check's reach, because the check looked for a phrase the file happens to write + across two lines. It matched nothing and reported success. It now tolerates + the formatting and fails when it finds nothing to match, so an empty result + can no longer read as a pass. +- Test fixtures that stated the score version by hand now read it from the same + place the application does, so a future change to it cannot leave them behind. + ## [1.35.3] — 2026-08-01 ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 01bb4b8d6..cd5d39b81 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.35.3 + version: 1.35.4 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 96e4eeb1f..21819c8f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.35.3", + "version": "1.35.4", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 23a1b22c7..e902efa2a 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.35.3"; + /* @sw-version-fallback */ "v1.35.4"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`