From 78611277fedae346cdcd8471193a4d9be28480ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 7 Nov 2026 01:26:17 +0100 Subject: [PATCH] fix(report): clamp a long report window in local days The 730-day clamp subtracted 730 times 24 hours from the end, which lands an hour off local midnight when an odd number of clock changes lies in between. The start is now the end's wall clock 730 calendar days back in the account's zone. --- src/app/api/export/health-record/route.ts | 65 ++++++++++---------- src/lib/__tests__/doctor-report-data.test.ts | 18 ++++++ src/lib/doctor-report-helpers.ts | 31 +++++++++- 3 files changed, 80 insertions(+), 34 deletions(-) diff --git a/src/app/api/export/health-record/route.ts b/src/app/api/export/health-record/route.ts index 99de75333..d4b32bb21 100644 --- a/src/app/api/export/health-record/route.ts +++ b/src/app/api/export/health-record/route.ts @@ -146,7 +146,8 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } - const range = normaliseDateRange(input.range ?? undefined); + const userTz = await resolveUserTimezone(user.id); + const range = normaliseDateRange(input.range, undefined, userTz); const practiceName = sanitisePracticeName(input.practiceName); const includeCharts = input.includeCharts ?? true; @@ -168,38 +169,36 @@ export const POST = apiHandler(async (request: NextRequest) => { input.germanAtc ?? (GERMAN_ATC_DEFAULT_LOCALES as readonly string[]).includes(locale); - const [data, userTz, userRow, allergyRows, familyHistoryRows] = - await Promise.all([ - collectDoctorReportData(user.id, range, selection, { practiceName }), - resolveUserTimezone(user.id), - prisma.user.findUnique({ - where: { id: user.id }, - select: { - insuranceNumberEncrypted: true, - timeFormat: true, - // Issue #922 — the exported PDF is spelled the way the person - // who exported it reads dates, not the locale default. - dateFormat: true, - }, - }), - // Structured records are always-available reference data (not - // time-windowed), so they ride alongside the report rather than through - // the aggregator. Owner-scoped, live rows only — and each read is gated - // on its own leaf, so a deselected section is not loaded, not merely - // dropped later. - selection.has("ALLERGIES") - ? prisma.allergy.findMany({ - where: { userId: user.id, deletedAt: null }, - orderBy: { createdAt: "desc" }, - }) - : Promise.resolve([]), - selection.has("FAMILY_HISTORY") - ? prisma.familyHistoryEntry.findMany({ - where: { userId: user.id, deletedAt: null }, - orderBy: { createdAt: "desc" }, - }) - : Promise.resolve([]), - ]); + const [data, userRow, allergyRows, familyHistoryRows] = await Promise.all([ + collectDoctorReportData(user.id, range, selection, { practiceName }), + prisma.user.findUnique({ + where: { id: user.id }, + select: { + insuranceNumberEncrypted: true, + timeFormat: true, + // Issue #922 — the exported PDF is spelled the way the person + // who exported it reads dates, not the locale default. + dateFormat: true, + }, + }), + // Structured records are always-available reference data (not + // time-windowed), so they ride alongside the report rather than through + // the aggregator. Owner-scoped, live rows only — and each read is gated + // on its own leaf, so a deselected section is not loaded, not merely + // dropped later. + selection.has("ALLERGIES") + ? prisma.allergy.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: { createdAt: "desc" }, + }) + : Promise.resolve([]), + selection.has("FAMILY_HISTORY") + ? prisma.familyHistoryEntry.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: { createdAt: "desc" }, + }) + : Promise.resolve([]), + ]); const fhirRecords = { allergies: allergyRows.map(toAllergyDTO), diff --git a/src/lib/__tests__/doctor-report-data.test.ts b/src/lib/__tests__/doctor-report-data.test.ts index 28126cae5..577a5dc0d 100644 --- a/src/lib/__tests__/doctor-report-data.test.ts +++ b/src/lib/__tests__/doctor-report-data.test.ts @@ -140,6 +140,24 @@ describe("normaliseDateRange", () => { expect(range.start.toISOString()).toBe("2024-01-02T00:00:00.001Z"); }); + it("clamps to whole local days across an odd number of clock changes", () => { + // The last day is 2026-03-29 in Berlin, the day summer time starts; the + // 730th day before it, 2024-03-30, is still in winter time. Five clock + // changes lie between, so 730 times 24 hours lands an hour off midnight. + const range = normaliseDateRange( + { + startDate: "2023-01-01T00:00:00.000Z", + endDate: "2026-03-29T21:59:59.999Z", + }, + NOW, + "Europe/Berlin", + ); + expect(range.days).toBe(730); + // Midnight on 2024-03-30, Berlin winter time. + expect(range.start.toISOString()).toBe("2024-03-29T23:00:00.000Z"); + expect(range.end.toISOString()).toBe("2026-03-29T21:59:59.999Z"); + }); + it("counts 730 local days as 730 across a net autumn clock change", () => { // 2024-10-26 .. 2026-10-25 in Berlin, cut the way the panel cuts it: // the first day starts in summer time, the last ends in winter time, so diff --git a/src/lib/doctor-report-helpers.ts b/src/lib/doctor-report-helpers.ts index cb5393307..4a1100579 100644 --- a/src/lib/doctor-report-helpers.ts +++ b/src/lib/doctor-report-helpers.ts @@ -19,6 +19,7 @@ import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec"; import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum"; import { userDayKey } from "@/lib/tz/resolver"; +import { wallClockInTz, zonedWallClockToUtc } from "@/lib/tz/wall-clock"; import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve"; import { buildComplianceMedicationContext, @@ -320,6 +321,28 @@ function parseIsoDate(value: unknown): Date | null { return d; } +/** + * The instant 1 ms after the same wall clock `days` calendar days before + * `end`, in `tz`. Offsets are whole minutes, so the milliseconds within the + * second carry over unchanged. + */ +function localDaysBefore(end: Date, days: number, tz: string): Date { + const at = wallClockInTz(end, tz); + const day = new Date(Date.UTC(at.year, at.month - 1, at.day - days)); + const start = zonedWallClockToUtc( + { + year: day.getUTCFullYear(), + month: day.getUTCMonth() + 1, + day: day.getUTCDate(), + hour: at.hour, + minute: at.minute, + second: at.second, + }, + tz, + ); + return new Date(start.getTime() + end.getUTCMilliseconds() + 1); +} + /** * Turn an arbitrary `{ startDate?, endDate?, days? }` payload into a validated * inclusive reporting window. @@ -330,6 +353,10 @@ function parseIsoDate(value: unknown): Date | null { * keeps its end and is clamped to the last 730 days before it; it used * to fall through to the 90-day default, a much shorter report than the * one asked for. The cover line prints the window the report covers. + * The 730 days are local days in `tz`: the start is the end's wall clock + * 730 calendar days back, plus 1 ms, so an end at 23:59:59.999 clamps to + * midnight however many clock changes lie between. Counting 730 × 24 h + * instead lands an hour off midnight across an odd number of them. * 2. Neither/invalid range, but a valid `days` integer (1..365) is provided * → fall back to "last `days` days ending now". * 3. Otherwise fall back to "last 90 days ending now" (the v1.4.14 default). @@ -341,6 +368,8 @@ function parseIsoDate(value: unknown): Date | null { export function normaliseDateRange( value: unknown, now: Date = new Date(), + /** The record's timezone; the clamp counts local days in it. */ + tz = "UTC", ): DoctorReportRange { const body = typeof value === "object" && value !== null @@ -358,7 +387,7 @@ export function normaliseDateRange( const spanDays = Math.ceil((spanMs - DST_SLACK_MS) / DAY_MS) || 1; if (spanDays > REPORT_MAX_RANGE_DAYS) { return { - start: new Date(endMs - REPORT_MAX_RANGE_DAYS * DAY_MS + 1), + start: localDaysBefore(endCandidate, REPORT_MAX_RANGE_DAYS, tz), end: endCandidate, days: REPORT_MAX_RANGE_DAYS, };