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

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