mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Count preventive-care days on the person's calendar, not in hours
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.
This commit is contained in:
1 parent
fb1bf710e6
commit
49ac6679c8
5 files changed
+294
-30
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]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user