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