fix(visits): cap the window before a visit at the longest report

The window before a booked visit started at the last matching visit
however far back it lay, while the health-report export covers 730 days
and fell back to its 90-day default for anything longer. The card then
offered a report the export did not build. The window now stops at the
export's limit, read from one shared constant, and preparation.capped says
when it did; the web says "The last two years" for it.

The export clamps an over-long explicit range to the last 730 days before
its end instead of falling back to 90 days, so a custom range or a linked
window cannot shrink silently either, and its day count forgives the hour
a clock change adds, so two years of local days count as 730.
This commit is contained in:
Marc-André Bombeck committed 2026-11-05 14:12:09 +01:00
1 parent f39598f2e7
commit da3d29424d
22 files changed
+282 -32

No files matched your search

@@ -197,6 +197,7 @@ describe("<VisitsSection> since the last visit", () => {
to: "2026-12-01",
basis: "practitioner",
since: sameDoctor,
capped: false,
},
});
const unprepared = visit({ ...next, preparation: null });
@@ -346,6 +347,7 @@ describe("<VisitsSection> since the last visit", () => {
to: "2026-12-01",
basis: "default",
since: null,
capped: false,
},
}),
],
+12 -1
View File
@@ -22,7 +22,10 @@ type Translate = (
export interface PreparationText {
/** Where the window starts: "Since your last visit with … on …". */
title: string;
/** Why, when the title alone does not say: only on the default window. */
/**
* Why, when the title alone does not say: on the default window, and on a
* window capped at the longest report.
*/
reason: string | null;
}
@@ -33,6 +36,14 @@ export function preparationText(
): PreparationText {
const date = formatDay(preparation.from);
const since = preparation.since;
// The matched visit is further back than a report covers: the window is
// the last two years, and the title must not promise everything since.
if (preparation.capped) {
return {
title: t("encounters.preparation.capped"),
reason: t("encounters.preparation.cappedReason"),
};
}
switch (preparation.basis) {
case "practitioner":
return {
@@ -127,6 +127,7 @@ describe("<ReportVisitOffer>", () => {
practitionerName: "Former skin practice",
specialty: "Dermatology",
},
capped: false,
},
}),
]);
@@ -144,6 +145,7 @@ describe("<ReportVisitOffer>", () => {
to: "2026-12-01",
basis: "default",
since: null,
capped: false,
},
}),
]);
@@ -153,6 +155,31 @@ describe("<ReportVisitOffer>", () => {
);
});
it("says the last two years on a window capped at the longest report", () => {
const html = render([
visit({
id: "derm",
preparation: {
from: "2024-12-02",
to: "2026-12-01",
basis: "practitioner",
since: {
id: "old-derm",
occurredAt: "2022-03-02T09:00:00.000Z",
kind: "SPECIALIST",
practitionerName: "Skin practice",
specialty: "Dermatology",
},
capped: true,
},
}),
]);
expect(html).toContain("The last two years");
expect(html).toContain("a report covers two years at most");
// Not "since your last visit", which would promise the whole stretch.
expect(html).not.toContain("Since your last visit");
});
it("applies the server's start and the appointment's day on a press", () => {
const at = new Date(Date.now() + 5 * DAY_MS);
const window = visitReportWindow(
@@ -164,6 +191,7 @@ describe("<ReportVisitOffer>", () => {
to: "2026-12-01",
basis: "default",
since: null,
capped: false,
},
}),
"Europe/Berlin",
@@ -188,6 +216,7 @@ describe("<ReportVisitOffer>", () => {
to: "2026-11-12",
basis: "default",
since: null,
capped: false,
},
});
expect(visitReportWindow(early, "America/New_York")).toEqual({
+20 -2
View File
@@ -126,7 +126,7 @@ describe("normaliseDateRange", () => {
expect(range.days).toBe(90);
});
it("falls back when range exceeds 730 days", () => {
it("clamps a range longer than 730 days to the last 730 before its end", () => {
const range = normaliseDateRange(
{
startDate: "2023-01-01T00:00:00.000Z",
@@ -134,7 +134,25 @@ describe("normaliseDateRange", () => {
},
NOW,
);
expect(range.days).toBe(90);
// Not the 90-day default: the end stays, the start moves up.
expect(range.days).toBe(730);
expect(range.end.toISOString()).toBe("2026-01-01T00:00:00.000Z");
expect(range.start.toISOString()).toBe("2024-01-02T00:00:00.001Z");
});
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
// the span runs an hour past 730 times 24 hours.
const start = new Date("2024-10-25T22:00:00.000Z");
const end = new Date("2026-10-25T22:59:59.999Z");
expect(end.getTime() - start.getTime()).toBeGreaterThan(730 * 86_400_000);
const range = normaliseDateRange(
{ startDate: start.toISOString(), endDate: end.toISOString() },
NOW,
);
expect(range.start.toISOString()).toBe(start.toISOString());
expect(range.days).toBe(730);
});
it("accepts the maximum 730-day window", () => {
+28 -12
View File
@@ -10,6 +10,7 @@
* itself stays in `doctor-report-data.ts`.
*/
import { REPORT_MAX_RANGE_DAYS } from "@/lib/doctor-report-range";
import type {
MeasurementSource,
MeasurementType,
@@ -247,20 +248,24 @@ export function normaliseDays(value: unknown, fallback = 90): number {
}
const MIN_RANGE_DAYS = 1;
/**
* Maximum span between `startDate` and `endDate`. Two years balances the
* "give me everything I have" case (chronic condition follow-up) against
* unbounded server work; the existing `days` fallback is capped at 365 so a
* caller asking for a 730-day window must come through the explicit-range
* surface and proves they want it.
*/
const MAX_RANGE_DAYS = 730;
// The maximum span between `startDate` and `endDate` is
// REPORT_MAX_RANGE_DAYS (730). Two years balances the "give me everything I
// have" case (chronic condition follow-up) against unbounded server work;
// the `days` fallback is capped at 365, so a 730-day window has to come
// through the explicit range.
const DEFAULT_RANGE_DAYS = 90;
const DAY_MS = 86_400_000;
/**
* A local day lasts 23 to 25 hours, so a range of whole local days can run
* an hour past the same number of 24-hour days. The day count forgives that
* hour: two years of local days are 730 days, not 731.
*/
const DST_SLACK_MS = 3_600_000;
/**
* Default ceiling on the number of `MedicationAdministration` source rows
* materialised per export, and the bounds an operator override is clamped
* to. The report window is already bounded at {@link MAX_RANGE_DAYS}
* to. The report window is already bounded at {@link REPORT_MAX_RANGE_DAYS}
* (730 days), but a chronic medication dosed several times a day across
* that window — multiplied by several such medications — can still produce
* thousands of administration resources in a single Bundle. The default of
@@ -321,7 +326,10 @@ function parseIsoDate(value: unknown): Date | null {
*
* Resolution order:
* 1. Both `startDate` AND `endDate` parse as valid ISO timestamps with
* `endDate >= startDate` and span <= 730 days → use them.
* `endDate >= startDate` and span <= 730 days → use them. A longer span
* 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.
* 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).
@@ -346,8 +354,16 @@ export function normaliseDateRange(
const endMs = endCandidate.getTime();
if (endMs >= startMs) {
const spanMs = endMs - startMs;
const spanDays = Math.ceil(spanMs / 86_400_000) || 1;
if (spanDays >= MIN_RANGE_DAYS && spanDays <= MAX_RANGE_DAYS) {
// `|| 1` also turns the -0 of a span shorter than the slack into one.
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),
end: endCandidate,
days: REPORT_MAX_RANGE_DAYS,
};
}
if (spanDays >= MIN_RANGE_DAYS) {
return {
start: startCandidate,
end: endCandidate,
+10
View File
@@ -0,0 +1,10 @@
/**
* The longest window a health report covers, in days, inclusive of both end
* days. The export clamps a longer range to the last this many days ending
* on its end (`normaliseDateRange`), and the window offered before a booked
* visit is capped at it (`resolvePreparation`), so the visit card never
* promises a report the export will not produce.
*
* Client-safe: no server import.
*/
export const REPORT_MAX_RANGE_DAYS = 730;
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import type { EncounterKind } from "@/generated/prisma/client";
import { REPORT_MAX_RANGE_DAYS } from "@/lib/doctor-report-range";
import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format";
import {
PREPARATION_DEFAULT_DAYS,
resolvePreparation,
@@ -76,6 +78,7 @@ describe("the window before a booked visit", () => {
practitionerName: "Skin practice",
specialty: "Dermatology",
},
capped: false,
});
});
@@ -103,6 +106,7 @@ describe("the window before a booked visit", () => {
to: "2026-10-23",
basis: "default",
since: null,
capped: false,
});
expect(PREPARATION_DEFAULT_DAYS).toBe(90);
});
@@ -152,6 +156,35 @@ describe("the window before a booked visit", () => {
expect(prep.since?.id).toBe("derm-a");
});
it("caps a window older than a report covers, still naming its visit", () => {
const derm = (id: string, at: string) =>
visit(id, at, {
kind: "SPECIALIST",
practitionerId: "p-derm",
name: "Skin practice",
specialty: "Dermatology",
});
const prep = resolve(next, [derm("old", "2023-04-03T09:00:00.000Z")]);
expect(prep).toMatchObject({
from: shiftDateKey("2026-10-23", -(REPORT_MAX_RANGE_DAYS - 1)),
to: "2026-10-23",
basis: "practitioner",
capped: true,
since: { id: "old", occurredAt: "2023-04-03T09:00:00.000Z" },
});
expect(daysBetweenDateKeys(prep.from, prep.to) + 1).toBe(
REPORT_MAX_RANGE_DAYS,
);
// One day inside the limit is left as it is.
const edge = shiftDateKey("2026-10-23", -(REPORT_MAX_RANGE_DAYS - 1));
expect(
resolve(next, [derm("edge", `${edge}T09:00:00.000Z`)]),
).toMatchObject({ from: edge, capped: false });
expect(
resolve(next, [derm("over", `${shiftDateKey(edge, -1)}T09:00:00.000Z`)]),
).toMatchObject({ from: edge, capped: true });
});
it("only counts visits before the booked one", () => {
const later = visit("later", "2026-10-24T09:00:00.000Z", {
practitionerId: "p-derm",
+23 -4
View File
@@ -26,7 +26,11 @@
* is no better a start than the default.
*
* The window starts on the matched visit's own day: a dose changed at that
* visit belongs to what the next one should know.
* visit belongs to what the next one should know. It covers at most
* {@link REPORT_MAX_RANGE_DAYS} days, the longest report the export
* produces: a matched visit further back moves `from` to the first day of
* that stretch and sets `capped`, so the report offered for the visit is the
* one the export builds.
*
* Resolved on the server, published on the visit list, never recomputed by a
* client. A person can still change both dates on the report.
@@ -34,7 +38,8 @@
* Pure and client-safe; the read lives in `preparation-read.ts`.
*/
import type { EncounterKind } from "@/generated/prisma/client";
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
import { REPORT_MAX_RANGE_DAYS } from "@/lib/doctor-report-range";
import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format";
/** The window when no earlier visit matches the booked one. */
export const PREPARATION_DEFAULT_DAYS = 90;
@@ -72,8 +77,17 @@ export interface EncounterPreparationDTO {
to: string;
/** Which rule chose `from`. */
basis: PreparationBasis;
/** The visit `from` is the day of; null on `default`. */
/**
* The visit the window is measured from; null on `default`. `from` is its
* day unless `capped`.
*/
since: PreparationAnchorDTO | null;
/**
* True when `since` lies further back than a report covers and `from` was
* moved to the first day of the last {@link REPORT_MAX_RANGE_DAYS} days
* before `to`.
*/
capped: boolean;
}
/** The fields the rule reads, off a booked visit or an earlier one. */
@@ -140,10 +154,14 @@ export function resolvePreparation(args: {
for (const [basis, matches] of rules) {
const hit = earlier.find(matches);
if (!hit) continue;
const day = userDayKey(hit.occurredAt, tz);
// Both end days count: `from` to `to` spans at most the report's days.
const capped = daysBetweenDateKeys(day, to) > REPORT_MAX_RANGE_DAYS - 1;
return {
from: userDayKey(hit.occurredAt, tz),
from: capped ? shiftDateKey(to, -(REPORT_MAX_RANGE_DAYS - 1)) : day,
to,
basis,
capped,
since: {
id: hit.id,
occurredAt: hit.occurredAt.toISOString(),
@@ -158,5 +176,6 @@ export function resolvePreparation(args: {
to,
basis: "default",
since: null,
capped: false,
};
}
+6 -1
View File
@@ -189,7 +189,7 @@ const encounterPreparation = z
from: z
.string()
.describe(
"First day of the window, `YYYY-MM-DD` on the record's own calendar: the day of `since`, or 90 days before today on `default`.",
"First day of the window, `YYYY-MM-DD` on the record's own calendar: the day of `since`, or 90 days before today on `default`. Never more than 730 days back from `to`, both days counted: the longest window the health-report export covers (see `capped`).",
),
to: z
.string()
@@ -202,6 +202,11 @@ const encounterPreparation = z
"The rule that chose `from`, first match wins over the visits that took place (DONE) before this one: `practitioner` (the last visit with the same practitioner), `specialty` (the last visit with a practitioner of the same specialty, compared without case or extra spaces), `kind` (the last visit of the same kind, for ROUTINE, PREVENTIVE and THERAPY only), `default` (none matched: the last 90 days).",
),
since: preparationAnchor.nullable(),
capped: z
.boolean()
.describe(
"True when `since` lies further back than a health report covers, so `from` is the first day of the last 730 days ending on `to` instead of the day of `since`. A client says the window covers the last two years rather than everything since that visit; a report for `from` to `to` is the one the export builds.",
),
})
.meta({
id: "EncounterPreparation",