fix(day): read dose and allergy dates as calendar dates

A dose date is stored at UTC midnight and an allergy onset at noon UTC;
neither has a time of day. The day view, the day index, the visit
preparation and the timeline treated both as instants in the person's
zone: west of UTC a dose landed on the previous day, and the day view
printed a clock time nobody entered (01:00 for a dose in Berlin, 13:00
for an onset).

These readers now take the date part, as the vaccination list and the
allergy form already do: the day and the index query the UTC calendar
day (the window documents already use), the events carry no instant,
and the timeline dates both kinds by their key. Correct for midnight
and noon rows in every zone, without touching stored data.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 19:47:53 +01:00
1 parent 1d0a2a8128
commit 9afcfd4351
6 files changed
+244 -37

No files matched your search

@@ -0,0 +1,177 @@
/**
* A vaccination and an allergy onset carry a date, not a time of day: a dose
* is stored at UTC midnight on its date, an onset typed in the form at noon
* UTC. The day view, the day index and the timeline used to read both as
* instants in the person's zone, so west of UTC a dose sat on the previous
* day, and the day view printed a clock time nobody entered ("01:00" for a
* dose in Berlin).
*
* The rows below are filtered by the window each reader asks for, so the
* assertions read which day a row lands on, not how the query is spelled.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
type Row = Record<string, unknown>;
const { tables } = vi.hoisted(() => ({
tables: {} as Record<string, Row[]>,
}));
function inWindow(value: unknown, window: unknown): boolean {
if (window === undefined) return true;
if (!(value instanceof Date)) return false;
const { gte, lt } = window as { gte?: Date; lt?: Date };
return (!gte || value >= gte) && (!lt || value < lt);
}
vi.mock("@/lib/db", () => {
const model = (name: string) => ({
findMany: async (args?: { where?: Record<string, unknown> }) => {
const where = args?.where ?? {};
return (tables[name] ?? []).filter(
(row) =>
inWindow(row.occurredAt, where.occurredAt) &&
inWindow(row.onsetAt, where.onsetAt),
);
},
});
return {
prisma: new Proxy({} as Record<string, unknown>, {
get: (_target, name: string) => model(name),
}),
};
});
vi.mock("@/lib/day/daily-stats", () => ({
readLocalDaysWithReadings: async () => [],
}));
vi.mock("@/lib/day/notable", () => ({ loadNotableRange: async () => [] }));
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: async () => null,
}));
import { RECORD_SECTION_READERS, type DayFrame } from "@/lib/day/records";
import { loadDayIndex } from "@/lib/day/day-index";
import { loadSinceLastVisit } from "@/lib/day/visit-preparation";
import type { DaySectionKey } from "@/lib/day/contract";
import type { DayAccess } from "@/lib/day/sections";
import { LANE_READERS } from "@/lib/timeline/lanes";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
import { shiftDateKey } from "@/lib/tz/format";
const TZ = "America/New_York";
function frame(day: string): DayFrame {
return {
userId: "u1",
day,
tz: TZ,
dayStart: startOfLocalDayKey(day, TZ),
dayEnd: startOfLocalDayKey(shiftDateKey(day, 1), TZ),
documentsReadable: false,
};
}
beforeEach(() => {
tables.vaccinationRecord = [
{
id: "dose-1",
occurredAt: new Date("2026-11-11T00:00:00.000Z"),
vaccineName: "Influenza",
antigenSlug: null,
doseNumber: null,
noteEncrypted: null,
customVaccine: null,
},
];
tables.allergy = [
{
id: "allergy-1",
substance: "Penicillin",
onsetAt: new Date("2026-11-11T12:00:00.000Z"),
severity: null,
status: "ACTIVE",
},
];
});
describe("the day view", () => {
it("files a dose and an allergy onset under their own date west of UTC", async () => {
const dose = await RECORD_SECTION_READERS.vaccinations!(
frame("2026-11-11"),
);
const onset = await RECORD_SECTION_READERS.allergies!(frame("2026-11-11"));
expect(dose.events.map((e) => e.id)).toEqual(["dose-1"]);
expect(onset.events.map((e) => e.id)).toEqual(["allergy-1"]);
const before = await RECORD_SECTION_READERS.vaccinations!(
frame("2026-11-10"),
);
expect(before.events).toEqual([]);
});
it("gives neither a clock time", async () => {
const dose = await RECORD_SECTION_READERS.vaccinations!(
frame("2026-11-11"),
);
const onset = await RECORD_SECTION_READERS.allergies!(frame("2026-11-11"));
expect(dose.events[0].at).toBeNull();
expect(onset.events[0].at).toBeNull();
});
});
describe("the day index", () => {
it("marks the date itself, not the evening before", async () => {
const access = {
readable: new Set<DaySectionKey>(["vaccinations", "allergies"]),
notShared: [],
modules: {},
moduleOff: [],
} as unknown as DayAccess;
const index = await loadDayIndex({
recordId: "u1",
from: "2026-11-01",
to: "2026-11-30",
access,
tz: TZ,
});
expect(index.days).toEqual({
"2026-11-11": ["allergies", "vaccinations"],
});
});
});
describe("the visit preparation", () => {
it("lists a dose under its own date", async () => {
const window = await loadSinceLastVisit({
recordId: "u1",
access: {
readable: new Set<DaySectionKey>(["vaccinations"]),
notShared: [],
modules: {},
moduleOff: [],
} as unknown as DayAccess,
tz: TZ,
today: "2026-11-30",
from: "2026-11-11",
to: "2026-11-30",
});
expect(window.changes.map((c) => [c.date, c.kind])).toEqual([
["2026-11-11", "vaccination"],
]);
});
});
describe("the timeline", () => {
it("starts a dose and an allergy on their own date", async () => {
const laneFrame = {
userId: "u1",
tz: TZ,
access: {} as never,
};
const doses = await LANE_READERS.vaccinations(laneFrame);
const allergies = await LANE_READERS.allergies(laneFrame);
expect(doses.items[0].start).toBe("2026-11-11");
expect(allergies.items[0].start).toBe("2026-11-11");
});
});
+33 -15
View File
@@ -102,6 +102,28 @@ const instantWindow = (frame: IndexFrame) => ({
lt: frame.end,
});
/**
* The window as UTC calendar days, for a column that holds a date rather
* than an instant (a dose date at UTC midnight, an allergy onset or a
* document date at noon UTC). Its date part is its day in every zone.
*/
const calendarWindow = (frame: IndexFrame) => ({
gte: dayKeyAsUtcMidnight(frame.from),
lt: dayKeyAsUtcMidnight(shiftDateKey(frame.to, 1)),
});
/** Mark the calendar date of every date-only value. */
function markDates(
frame: IndexFrame,
section: DaySectionKey,
dates: ReadonlyArray<Date | null>,
): void {
for (const date of dates) {
if (date)
mark(frame.marks, dateOnlyKey(date), section, frame.from, frame.to);
}
}
/** The per-section readers of the index. */
const INDEX_READERS: Partial<
Record<DaySectionKey, (frame: IndexFrame) => Promise<void>>
@@ -232,11 +254,11 @@ const INDEX_READERS: Partial<
where: {
userId: frame.userId,
deletedAt: null,
onsetAt: instantWindow(frame),
onsetAt: calendarWindow(frame),
},
select: { onsetAt: true },
});
markInstants(
markDates(
frame,
"allergies",
rows.map((r) => r.onsetAt),
@@ -278,11 +300,11 @@ const INDEX_READERS: Partial<
where: {
userId: frame.userId,
deletedAt: null,
occurredAt: instantWindow(frame),
occurredAt: calendarWindow(frame),
},
select: { occurredAt: true },
});
markInstants(
markDates(
frame,
"vaccinations",
rows.map((r) => r.occurredAt),
@@ -305,24 +327,20 @@ const INDEX_READERS: Partial<
);
},
async documents(frame) {
const start = dayKeyAsUtcMidnight(frame.from);
const end = dayKeyAsUtcMidnight(shiftDateKey(frame.to, 1));
const days = calendarWindow(frame);
const rows = await prisma.inboundDocument.findMany({
where: {
userId: frame.userId,
deletedAt: null,
OR: [
{ reportDate: { gte: start, lt: end } },
{ reportDate: null, documentDate: { gte: start, lt: end } },
],
OR: [{ reportDate: days }, { reportDate: null, documentDate: days }],
},
select: { reportDate: true, documentDate: true },
});
for (const row of rows) {
const date = row.reportDate ?? row.documentDate;
if (date)
mark(frame.marks, dateOnlyKey(date), "documents", frame.from, frame.to);
}
markDates(
frame,
"documents",
rows.map((r) => r.reportDate ?? r.documentDate),
);
},
async mood(frame) {
const rows = await prisma.moodEntry.findMany({
+23 -14
View File
@@ -130,6 +130,19 @@ function dateKeyOrNull(value: Date | null): string | null {
return value ? dateOnlyKey(value) : null;
}
/**
* The day as a window over a column that holds a date, not an instant (a
* dose date at UTC midnight, an allergy onset or a document date at noon
* UTC). Its date part decides the day, whatever the person's zone; the
* local day window put a UTC-midnight dose on the previous day west of UTC.
*/
function calendarDayWindow(day: string): { gte: Date; lt: Date } {
return {
gte: dayKeyAsUtcMidnight(day),
lt: dayKeyAsUtcMidnight(shiftDateKey(day, 1)),
};
}
/* ─── medications ─────────────────────────────────────────────────────────── */
async function medicationsPart(frame: DayFrame): Promise<SectionPart> {
@@ -578,15 +591,16 @@ async function allergiesPart(frame: DayFrame): Promise<SectionPart> {
where: {
userId: frame.userId,
deletedAt: null,
onsetAt: { gte: frame.dayStart, lt: frame.dayEnd },
onsetAt: calendarDayWindow(frame.day),
},
select: { id: true, substance: true, onsetAt: true, severity: true },
select: { id: true, substance: true, severity: true },
});
return {
running: [],
events: rows.map((row) =>
event({
at: row.onsetAt ? row.onsetAt.toISOString() : null,
// A date without a time of day: no clock time to print.
at: null,
kind: "allergyOnset",
section: "allergies",
id: row.id,
@@ -648,11 +662,10 @@ async function vaccinationsPart(frame: DayFrame): Promise<SectionPart> {
where: {
userId: frame.userId,
deletedAt: null,
occurredAt: { gte: frame.dayStart, lt: frame.dayEnd },
occurredAt: calendarDayWindow(frame.day),
},
select: {
id: true,
occurredAt: true,
vaccineName: true,
antigenSlug: true,
doseNumber: true,
@@ -670,7 +683,8 @@ async function vaccinationsPart(frame: DayFrame): Promise<SectionPart> {
running: [],
events: rows.map((row) =>
event({
at: row.occurredAt.toISOString(),
// A dose carries a date, never a time of day.
at: null,
kind: "vaccination",
section: "vaccinations",
id: row.id,
@@ -761,18 +775,13 @@ async function checkupsPart(frame: DayFrame): Promise<SectionPart> {
async function documentsPart(frame: DayFrame): Promise<SectionPart> {
// A document's date is a calendar date stored at noon UTC (older rows at
// UTC midnight): both anchors fall inside this UTC calendar day, and the
// date key decides. The stated report date wins over the filing date.
const start = dayKeyAsUtcMidnight(frame.day);
const end = dayKeyAsUtcMidnight(shiftDateKey(frame.day, 1));
// UTC midnight). The stated report date wins over the filing date.
const day = calendarDayWindow(frame.day);
const rows = await prisma.inboundDocument.findMany({
where: {
userId: frame.userId,
deletedAt: null,
OR: [
{ reportDate: { gte: start, lt: end } },
{ reportDate: null, documentDate: { gte: start, lt: end } },
],
OR: [{ reportDate: day }, { reportDate: null, documentDate: day }],
},
select: {
id: true,
+7 -2
View File
@@ -240,7 +240,12 @@ async function vaccinationChanges(w: Window): Promise<DayChange[]> {
where: {
userId: w.userId,
deletedAt: null,
occurredAt: { gte: w.start, lt: w.end },
// A dose date is stored at UTC midnight: its date part is its day in
// every zone, so the window is UTC calendar days, not local instants.
occurredAt: {
gte: dayKeyAsUtcMidnight(w.from),
lt: dayKeyAsUtcMidnight(shiftDateKey(w.to, 1)),
},
},
select: {
id: true,
@@ -251,7 +256,7 @@ async function vaccinationChanges(w: Window): Promise<DayChange[]> {
},
});
return rows.map((row) =>
change(userDayKey(row.occurredAt, w.tz), "vaccinations", {
change(dateOnlyKey(row.occurredAt), "vaccinations", {
kind: "vaccination",
id: row.id,
title:
+2 -2
View File
@@ -204,7 +204,7 @@ async function allergiesLane(frame: LaneFrame): Promise<LaneRead> {
item({
id: r.id,
kind: "allergy",
start: userDayKey(r.onsetAt, frame.tz),
start: dateOnlyKey(r.onsetAt),
end: null,
open: r.status === "ACTIVE",
label: r.substance,
@@ -388,7 +388,7 @@ async function vaccinationsLane(frame: LaneFrame): Promise<LaneRead> {
item({
id: r.id,
kind: "vaccination",
start: userDayKey(r.occurredAt, frame.tz),
start: dateOnlyKey(r.occurredAt),
end: null,
open: false,
label: r.vaccineName ?? r.customVaccine?.name ?? r.antigenSlug ?? "",
+2 -4
View File
@@ -271,9 +271,7 @@ async function allergiesLane(frame: Frame) {
const detail = { key: "allergies", params: { count: rows.length } };
const dated = rows.filter((r) => r.onsetAt !== null);
if (dated.length > 0) {
const since = dated
.map((r) => userDayKey(r.onsetAt as Date, frame.tz))
.sort()[0];
const since = dated.map((r) => dateOnlyKey(r.onsetAt as Date)).sort()[0];
return lane("carries", rows.length, since, { detail });
}
// Undated allergies are standing items, not a shortcoming: no gap.
@@ -351,7 +349,7 @@ async function vaccinationsLane(frame: Frame) {
gaps: [gap("vaccinationsEmpty", 0, "/vaccinations")],
});
}
return lane("carries", count, userDayKey(first.occurredAt, frame.tz), {
return lane("carries", count, dateOnlyKey(first.occurredAt), {
detail: { key: "vaccinations", params: { count } },
});
}