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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-07 01:33:19 +01:00
1 parent 46a1a5d320
commit 78611277fe
3 files changed
+80 -34

No files matched your search

+32 -33
View File
@@ -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),
@@ -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
+30 -1
View File
@@ -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,
};