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 }; +}