From da671f74aa40ddf9773af498724aa15f9b9d0a3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:12:44 +0100 Subject: [PATCH] refactor(day): share one window for date-only columns The day view, the day index and the visit preparation each built the UTC calendar window for a date-only column by hand (documents had a fourth copy). `dateOnlyWindow()` beside the other date-only helpers replaces them. The report name select becomes one spread, the share page builds its calendar formatter outside the view, and the PDF section reads it off the context, so the functions these fixes touched stay within their size budget; `DocumentRecordLinks` drops under it. --- .../baselines/source-function-lines.json | 5 ++- src/components/clinician/clinician-view.tsx | 17 ++++++---- .../documents/document-record-links.tsx | 10 ++---- src/lib/account-name.ts | 8 +++++ .../day/__tests__/date-only-sections.test.ts | 11 +++++-- src/lib/day/day-index.ts | 17 +++++----- src/lib/day/records.ts | 31 ++++++------------- src/lib/day/visit-preparation.ts | 13 ++++---- .../clinical-records-notes-section.ts | 16 ++-------- src/lib/doctor-report/collect.ts | 7 ++--- src/lib/tz/__tests__/date-only.test.ts | 13 ++++++++ src/lib/tz/date-only.ts | 16 ++++++++++ 12 files changed, 90 insertions(+), 74 deletions(-) diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index dd83b4bdc..7861139a9 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 344, - "total": 111520, + "count": 343, + "total": 111318, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -94,7 +94,6 @@ "components/documents/document-facts-review.tsx :: DocumentFactsSection": 242, "components/documents/document-facts-review.tsx :: FactRow": 261, "components/documents/document-filter-bar.tsx :: DocumentFilterBar": 348, - "components/documents/document-record-links.tsx :: DocumentRecordLinks": 202, "components/documents/document-timeline.tsx :: DocumentTimeline": 314, "components/documents/documents-view.tsx :: DocumentsView": 303, "components/documents/sources/document-source-picker.tsx :: DocumentSourcePicker": 563, diff --git a/src/components/clinician/clinician-view.tsx b/src/components/clinician/clinician-view.tsx index 5480c050d..43be0b2a6 100644 --- a/src/components/clinician/clinician-view.tsx +++ b/src/components/clinician/clinician-view.tsx @@ -134,6 +134,15 @@ interface ClinicianViewProps { dateFormat: DateFormatPreference; } +/** + * A date-only value (a date of birth, a dose date) as its calendar date in + * the owner's date order, the same in every zone. Read as an instant in the + * owner's zone it fell on the previous day west of UTC. + */ +function calendarDateIn(dateFormat: DateFormatPreference, locale: Locale) { + return (iso: string) => formatDate(iso.slice(0, 10), dateFormat, locale); +} + export function ClinicianView({ t, label, @@ -154,10 +163,6 @@ export function ClinicianView({ // Owner hour cycle + date order ride along (issue #922). const fmt = makeFormatters(locale, timezone, timeFormat, dateFormat); const fmtDate = (iso: string) => fmt.date(new Date(iso)); - // A date-only value (a date of birth, a dose date) is its calendar date in - // every zone; read as an instant it fell on the previous day west of UTC. - const fmtCalendarDate = (iso: string) => - formatDate(iso.slice(0, 10), dateFormat, locale); const fmtDateTime = (iso: string) => fmt.dateTime(new Date(iso)); // Two decimals at most, in the owner's locale: a German reader gets // "80,5", not the "80.5" a bare number prints. @@ -204,7 +209,7 @@ export function ClinicianView({ t={t} report={report} scope={scope} - fmtCalendarDate={fmtCalendarDate} + fmtCalendarDate={calendarDateIn(dateFormat, locale)} /> {/* ── measurements ─────────────────────────────────────── */} @@ -303,7 +308,7 @@ export function ClinicianView({ t={t} report={report} scope={scope} - fmtCalendarDate={fmtCalendarDate} + fmtCalendarDate={calendarDateIn(dateFormat, locale)} /> diff --git a/src/components/documents/document-record-links.tsx b/src/components/documents/document-record-links.tsx index fcfce50e6..c4de521b8 100644 --- a/src/components/documents/document-record-links.tsx +++ b/src/components/documents/document-record-links.tsx @@ -188,10 +188,7 @@ export function DocumentRecordLinks({ const [visitIds, setVisitIds] = useState(null); const [doseIds, setDoseIds] = useState(null); - const labels = { - suggested: t("links.picker.suggested"), - date: (iso: string) => format.date(iso), - }; + const suggested = t("links.picker.suggested"); const visitOptions = recordOptions( [...encounters.items] @@ -205,9 +202,8 @@ export function DocumentRecordLinks({ href: visitHref(visit.id), })), anchor, - labels, + { suggested, date: format.date }, ); - // A dose date is a calendar date at UTC midnight, never an instant. const doseOptions = recordOptions( (vaccinations.data?.vaccinations ?? []).map((dose) => ({ id: dose.id, @@ -216,7 +212,7 @@ export function DocumentRecordLinks({ href: doseHref(dose.id), })), anchor, - { ...labels, date: calendarDate }, + { suggested, date: calendarDate }, // a dose date is a calendar date ); const showDoses = vaccinationsOn && doc.vaccinationLinks !== null; diff --git a/src/lib/account-name.ts b/src/lib/account-name.ts index 7e39c7319..b02c371ab 100644 --- a/src/lib/account-name.ts +++ b/src/lib/account-name.ts @@ -12,6 +12,14 @@ export function accountName(user: { return displayName ? displayName : user.username; } +/** The columns `patientName()` reads, for a Prisma `select`. */ +export const PATIENT_NAME_SELECT = { + username: true, + displayName: true, + fullName: true, + managedProfileAt: true, +} as const; + /** * The name a clinical document gives a person: the full name when one is * set, the display name next, the login last. A managed record's login is a diff --git a/src/lib/day/__tests__/date-only-sections.test.ts b/src/lib/day/__tests__/date-only-sections.test.ts index 90b22fb76..1c36c036e 100644 --- a/src/lib/day/__tests__/date-only-sections.test.ts +++ b/src/lib/day/__tests__/date-only-sections.test.ts @@ -55,7 +55,8 @@ 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 { LANE_READERS, type TimelineAccess } from "@/lib/timeline/lanes"; +import { MODULE_KEYS } from "@/lib/modules/gate"; import { startOfLocalDayKey } from "@/lib/tz/local-day"; import { shiftDateKey } from "@/lib/tz/format"; @@ -167,7 +168,13 @@ describe("the timeline", () => { const laneFrame = { userId: "u1", tz: TZ, - access: {} as never, + access: { + modules: Object.fromEntries( + MODULE_KEYS.map((key) => [key, true]), + ) as TimelineAccess["modules"], + domainVisible: () => true, + owner: true, + }, }; const doses = await LANE_READERS.vaccinations(laneFrame); const allergies = await LANE_READERS.allergies(laneFrame); diff --git a/src/lib/day/day-index.ts b/src/lib/day/day-index.ts index 28ae9d84d..7773c2379 100644 --- a/src/lib/day/day-index.ts +++ b/src/lib/day/day-index.ts @@ -33,7 +33,11 @@ import { SCREENER_INSTRUMENTS } from "@/lib/validations/mental-health"; import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking"; import { effectiveMoodTz } from "@/lib/mood/date-key"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; -import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; +import { + dateOnlyKey, + dateOnlyWindow, + dayKeyAsUtcMidnight, +} from "@/lib/tz/date-only"; import { shiftDateKey, userDayKey } from "@/lib/tz/format"; import { startOfLocalDayKey } from "@/lib/tz/local-day"; @@ -102,15 +106,8 @@ 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)), -}); +const calendarWindow = (frame: IndexFrame) => + dateOnlyWindow(frame.from, frame.to); /** Mark the calendar date of every date-only value. */ function markDates( diff --git a/src/lib/day/records.ts b/src/lib/day/records.ts index 139cdd416..a41d23bac 100644 --- a/src/lib/day/records.ts +++ b/src/lib/day/records.ts @@ -39,7 +39,11 @@ import { phaseForDate } from "@/lib/cycle/engine-adapter"; import { LUTEAL_DEFAULT } from "@/lib/cycle/types"; import { resolveCycleDay } from "@/lib/cycle/verdict"; import { effectiveMoodTz } from "@/lib/mood/date-key"; -import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; +import { + dateOnlyKey, + dateOnlyWindow, + dayKeyAsUtcMidnight, +} from "@/lib/tz/date-only"; import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format"; import { visitPath } from "@/lib/encounters/visit-links"; import { @@ -130,19 +134,6 @@ 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 { @@ -591,7 +582,7 @@ async function allergiesPart(frame: DayFrame): Promise { where: { userId: frame.userId, deletedAt: null, - onsetAt: calendarDayWindow(frame.day), + onsetAt: dateOnlyWindow(frame.day), }, select: { id: true, substance: true, severity: true }, }); @@ -599,8 +590,7 @@ async function allergiesPart(frame: DayFrame): Promise { running: [], events: rows.map((row) => event({ - // A date without a time of day: no clock time to print. - at: null, + at: null, // a date without a time of day kind: "allergyOnset", section: "allergies", id: row.id, @@ -662,7 +652,7 @@ async function vaccinationsPart(frame: DayFrame): Promise { where: { userId: frame.userId, deletedAt: null, - occurredAt: calendarDayWindow(frame.day), + occurredAt: dateOnlyWindow(frame.day), }, select: { id: true, @@ -683,8 +673,7 @@ async function vaccinationsPart(frame: DayFrame): Promise { running: [], events: rows.map((row) => event({ - // A dose carries a date, never a time of day. - at: null, + at: null, // a date without a time of day kind: "vaccination", section: "vaccinations", id: row.id, @@ -776,7 +765,7 @@ async function checkupsPart(frame: DayFrame): Promise { async function documentsPart(frame: DayFrame): Promise { // A document's date is a calendar date stored at noon UTC (older rows at // UTC midnight). The stated report date wins over the filing date. - const day = calendarDayWindow(frame.day); + const day = dateOnlyWindow(frame.day); const rows = await prisma.inboundDocument.findMany({ where: { userId: frame.userId, diff --git a/src/lib/day/visit-preparation.ts b/src/lib/day/visit-preparation.ts index 533f530d9..7c83d7a0f 100644 --- a/src/lib/day/visit-preparation.ts +++ b/src/lib/day/visit-preparation.ts @@ -23,7 +23,11 @@ import { openText } from "@/lib/day/records"; import { measurementTypeVisible, type DayAccess } from "@/lib/day/sections"; import { prisma } from "@/lib/db"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; -import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; +import { + dateOnlyKey, + dateOnlyWindow, + dayKeyAsUtcMidnight, +} from "@/lib/tz/date-only"; import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format"; import { startOfLocalDayKey } from "@/lib/tz/local-day"; import { visitPath } from "@/lib/encounters/visit-links"; @@ -240,12 +244,7 @@ async function vaccinationChanges(w: Window): Promise { where: { userId: w.userId, deletedAt: null, - // 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)), - }, + occurredAt: dateOnlyWindow(w.from, w.to), // a date, not an instant }, select: { id: true, diff --git a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts index 40c314fd4..32a8d02ab 100644 --- a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts +++ b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts @@ -23,17 +23,8 @@ export function buildClinicalRecordsNotesSection( context: DoctorReportPdfRenderContext, state: DoctorReportPdfCursorState, ): DoctorReportPdfCursorState { - const { - doc, - data, - t, - num, - fmtDate, - fmtCalendarDate, - margin, - tableBottomMargin, - ensureSpace, - } = context; + const { doc, data, t, num, fmtDate, margin, tableBottomMargin, ensureSpace } = + context; let y = state.y; // v1.17.1 — structured lab-results section. Populated when the `labs` @@ -431,8 +422,7 @@ export function buildClinicalRecordsNotesSection( }; const immunizationRows = data.immunizations.map((row) => [ - // A dose date is a calendar date at UTC midnight, never an instant. - fmtCalendarDate(row.occurredAt), + context.fmtCalendarDate(row.occurredAt), row.antigenSlug ? t(`vaccinations.catalog.${row.antigenSlug}`) : (row.vaccineName ?? "—"), diff --git a/src/lib/doctor-report/collect.ts b/src/lib/doctor-report/collect.ts index 5d76aafd3..f55d2f66c 100644 --- a/src/lib/doctor-report/collect.ts +++ b/src/lib/doctor-report/collect.ts @@ -84,7 +84,7 @@ import { loadLabResults, } from "./clinical-records"; import { dayKeyAsUtcMidnight } from "@/lib/tz/date-only"; -import { patientName } from "@/lib/account-name"; +import { PATIENT_NAME_SELECT, patientName } from "@/lib/account-name"; import { readingsMean } from "@/lib/measurements/day-mean"; import { loadScreenerUnvalidatedTranslations } from "./screener-translations"; @@ -179,9 +179,7 @@ export async function collectDoctorReportData( const userProfile = await prisma.user.findUnique({ where: { id: userId }, select: { - username: true, - displayName: true, - managedProfileAt: true, + ...PATIENT_NAME_SELECT, dateOfBirth: true, gender: true, heightCm: true, @@ -189,7 +187,6 @@ export async function collectDoctorReportData( thresholdsJson: true, timezone: true, sourcePriorityJson: true, - fullName: true, insurerName: true, insurerIkNumber: true, }, diff --git a/src/lib/tz/__tests__/date-only.test.ts b/src/lib/tz/__tests__/date-only.test.ts index 541fddbce..3aca0df8c 100644 --- a/src/lib/tz/__tests__/date-only.test.ts +++ b/src/lib/tz/__tests__/date-only.test.ts @@ -3,6 +3,7 @@ import { isNoonUtcAnchor, dateOnlyAtNoonUtc, dateOnlyKey, + dateOnlyWindow, dayKeyAsUtcMidnight, isCalendarDateKey, isDateOnlyKey, @@ -60,3 +61,15 @@ describe("isNoonUtcAnchor", () => { expect(isNoonUtcAnchor(new Date("2025-10-04T20:30:00.000Z"))).toBe(false); }); }); + +describe("dateOnlyWindow", () => { + it("spans UTC midnight to the next UTC midnight, both days included", () => { + expect(dateOnlyWindow("2026-11-11")).toEqual({ + gte: new Date("2026-11-11T00:00:00.000Z"), + lt: new Date("2026-11-12T00:00:00.000Z"), + }); + expect(dateOnlyWindow("2026-12-31", "2027-01-01").lt).toEqual( + new Date("2027-01-02T00:00:00.000Z"), + ); + }); +}); diff --git a/src/lib/tz/date-only.ts b/src/lib/tz/date-only.ts index 5018f530a..4a94de2cd 100644 --- a/src/lib/tz/date-only.ts +++ b/src/lib/tz/date-only.ts @@ -98,3 +98,19 @@ export function dateOnlyKey(value: Date): string { export function dayKeyAsUtcMidnight(key: string): Date { return new Date(`${key}T00:00:00.000Z`); } + +/** + * The window over a date-only column that holds the calendar days `fromKey` + * to `toKey`, both included: UTC midnight to the next UTC midnight, so both + * anchors (midnight and noon UTC) fall on their own date. A date-only value + * read through a person's local-day window sat on the evening before west of + * UTC. + */ +export function dateOnlyWindow( + fromKey: string, + toKey: string = fromKey, +): { gte: Date; lt: Date } { + const lt = dayKeyAsUtcMidnight(toKey); + lt.setUTCDate(lt.getUTCDate() + 1); + return { gte: dayKeyAsUtcMidnight(fromKey), lt }; +}