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

+9
View File
@@ -29,6 +29,15 @@
### Fixed
- **The report offered for a visit is the report you get.** When the last
matching visit was more than two years ago, the next-visit card offered a
report from that day, but a report covers two years at most, and the
export quietly made a 90-day report instead. The window before a visit
now stops at two years and says "The last two years", and the visit list
marks it with `preparation.capped`. A report range longer than two years,
however it is asked for, now covers the last two years of it rather than
falling back to 90 days. (Two years across a clock change also no longer
count as one day too many.)
- **PDF previews show in Safari.** On macOS and iOS the preview of an
uploaded PDF stayed an empty box. The page's security policy told the
browser that nothing may frame it, and Safari applied that to the
+8 -1
View File
@@ -78505,7 +78505,8 @@ components:
from:
type: string
description: "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`."
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:
type: string
description: The booked visit's own day, `YYYY-MM-DD` on the same calendar as `from` (the record's, not the reader's). A
@@ -78525,11 +78526,17 @@ components:
anyOf:
- $ref: "#/components/schemas/EncounterPreparationAnchor"
- type: "null"
capped:
type: boolean
description: 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.
required:
- from
- to
- basis
- since
- capped
additionalProperties: false
description: "The window before a booked visit, resolved server-side: where the visit preparation and the doctor report
offered for the appointment start, and why there. Never the last visit of any kind, which is how a dermatology
+3 -1
View File
@@ -13339,7 +13339,9 @@
"specialty": "Seit deinem letzten Termin ({specialty}) am {date}",
"kind": "Seit deinem letzten Termin der gleichen Art ({kind}) am {date}",
"default": "Die letzten {days} Tage",
"defaultReason": "Kein früherer Termin bei derselben Praxis, in derselben Fachrichtung oder derselben Art."
"defaultReason": "Kein früherer Termin bei derselben Praxis, in derselben Fachrichtung oder derselben Art.",
"capped": "Die letzten zwei Jahre",
"cappedReason": "Der letzte passende Termin liegt mehr als zwei Jahre zurück, und ein Bericht umfasst höchstens zwei Jahre."
}
},
"checkups": {
+3 -1
View File
@@ -13339,7 +13339,9 @@
"specialty": "Since your last {specialty} visit on {date}",
"kind": "Since your last visit of the same kind ({kind}) on {date}",
"default": "The last {days} days",
"defaultReason": "No earlier visit with the same practice, specialty or kind."
"defaultReason": "No earlier visit with the same practice, specialty or kind.",
"capped": "The last two years",
"cappedReason": "The last matching visit is more than two years ago, and a report covers two years at most."
}
},
"checkups": {
+3 -1
View File
@@ -13339,7 +13339,9 @@
"specialty": "Desde tu última visita ({specialty}) el {date}",
"kind": "Desde tu última visita del mismo tipo ({kind}) el {date}",
"default": "Los últimos {days} días",
"defaultReason": "No hay ninguna visita anterior con la misma consulta, especialidad o tipo."
"defaultReason": "No hay ninguna visita anterior con la misma consulta, especialidad o tipo.",
"capped": "Los últimos dos años",
"cappedReason": "La última visita coincidente fue hace más de dos años, y un informe abarca como máximo dos años."
}
},
"checkups": {
+3 -1
View File
@@ -13339,7 +13339,9 @@
"specialty": "Depuis votre dernière visite ({specialty}) le {date}",
"kind": "Depuis votre dernière visite du même type ({kind}) le {date}",
"default": "Les {days} derniers jours",
"defaultReason": "Aucune visite antérieure au même cabinet, dans la même spécialité ou du même type."
"defaultReason": "Aucune visite antérieure au même cabinet, dans la même spécialité ou du même type.",
"capped": "Les deux dernières années",
"cappedReason": "La dernière consultation correspondante date de plus de deux ans, et un rapport couvre au plus deux ans."
}
},
"checkups": {
+3 -1
View File
@@ -13339,7 +13339,9 @@
"specialty": "Dalla tua ultima visita ({specialty}) del {date}",
"kind": "Dalla tua ultima visita dello stesso tipo ({kind}) del {date}",
"default": "Gli ultimi {days} giorni",
"defaultReason": "Nessuna visita precedente presso lo stesso studio, nella stessa specialità o dello stesso tipo."
"defaultReason": "Nessuna visita precedente presso lo stesso studio, nella stessa specialità o dello stesso tipo.",
"capped": "Gli ultimi due anni",
"cappedReason": "L'ultima visita corrispondente risale a più di due anni fa, e un report copre al massimo due anni."
}
},
"checkups": {
+3 -1
View File
@@ -13339,7 +13339,9 @@
"specialty": "{date} 마지막 {specialty} 방문 이후",
"kind": "{date} 같은 유형({kind})의 마지막 방문 이후",
"default": "최근 {days}일",
"defaultReason": "같은 병원, 진료과 또는 유형의 이전 방문이 없어요."
"defaultReason": "같은 병원, 진료과 또는 유형의 이전 방문이 없어요.",
"capped": "최근 2년",
"cappedReason": "마지막으로 일치하는 진료가 2년 이상 지났으며, 보고서는 최대 2년까지 다룹니다."
}
},
"checkups": {
+3 -1
View File
@@ -13339,7 +13339,9 @@
"specialty": "Sinds je laatste bezoek ({specialty}) op {date}",
"kind": "Sinds je laatste bezoek van hetzelfde soort ({kind}) op {date}",
"default": "De afgelopen {days} dagen",
"defaultReason": "Geen eerder bezoek bij dezelfde praktijk, in hetzelfde specialisme of van hetzelfde soort."
"defaultReason": "Geen eerder bezoek bij dezelfde praktijk, in hetzelfde specialisme of van hetzelfde soort.",
"capped": "De afgelopen twee jaar",
"cappedReason": "Het laatste passende bezoek is meer dan twee jaar geleden, en een rapport beslaat hooguit twee jaar."
}
},
"checkups": {
+3 -1
View File
@@ -13339,7 +13339,9 @@
"specialty": "Od ostatniej wizyty ({specialty}) {date}",
"kind": "Od ostatniej wizyty tego samego rodzaju ({kind}) {date}",
"default": "Ostatnie {days} dni",
"defaultReason": "Brak wcześniejszej wizyty w tym samym gabinecie, tej samej specjalizacji lub tego samego rodzaju."
"defaultReason": "Brak wcześniejszej wizyty w tym samym gabinecie, tej samej specjalizacji lub tego samego rodzaju.",
"capped": "Ostatnie dwa lata",
"cappedReason": "Ostatnia pasująca wizyta była ponad dwa lata temu, a raport obejmuje najwyżej dwa lata."
}
},
"checkups": {
+3 -1
View File
@@ -13339,7 +13339,9 @@
"specialty": "Desde a sua última consulta de {specialty}, em {date}",
"kind": "Desde a sua última consulta do mesmo tipo ({kind}), em {date}",
"default": "Os últimos {days} dias",
"defaultReason": "Nenhuma consulta anterior com o mesmo consultório, especialidade ou tipo."
"defaultReason": "Nenhuma consulta anterior com o mesmo consultório, especialidade ou tipo.",
"capped": "Os últimos dois anos",
"cappedReason": "A última consulta correspondente foi há mais de dois anos, e um relatório cobre no máximo dois anos."
}
},
"checkups": {
+3 -1
View File
@@ -13339,7 +13339,9 @@
"specialty": "С вашего последнего визита к специалисту «{specialty}» {date}",
"kind": "С вашего последнего визита того же вида ({kind}) {date}",
"default": "Последние {days} дней",
"defaultReason": "Нет более раннего визита с той же клиникой, специальностью или видом."
"defaultReason": "Нет более раннего визита с той же клиникой, специальностью или видом.",
"capped": "Последние два года",
"cappedReason": "Последний подходящий визит был более двух лет назад, а отчёт охватывает не больше двух лет."
}
},
"checkups": {
@@ -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",
@@ -14,8 +14,11 @@ process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { GET as listEncounters } from "@/app/api/encounters/route";
import { normaliseDateRange } from "@/lib/doctor-report-helpers";
import { REPORT_MAX_RANGE_DAYS } from "@/lib/doctor-report-range";
import type { EncounterListDTO } from "@/lib/encounters/dto";
import { shiftDateKey } from "@/lib/tz/format";
import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format";
import { startOfLocalDayKey } from "@/lib/tz/local-day";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
@@ -147,6 +150,7 @@ describe("GET /api/encounters — the window before a booked visit", () => {
practitionerName: "Skin practice",
specialty: "Dermatology",
},
capped: false,
});
// Only a booked visit carries one.
expect(body.past.every((v) => v.preparation === null)).toBe(true);
@@ -207,6 +211,7 @@ describe("GET /api/encounters — the window before a booked visit", () => {
to: berlinDay(next.occurredAt),
basis: "default",
since: null,
capped: false,
});
});
@@ -230,6 +235,72 @@ describe("GET /api/encounters — the window before a booked visit", () => {
).toBe(anchor.id);
});
it("caps a window older than a report covers, and the export builds that report", async () => {
const derm = await practitioner("Skin practice", "Dermatology");
// Three years back: further than the 730 days a report covers.
const anchor = await encounter({
occurredAt: daysFromNow(-1100),
practitionerId: derm.id,
});
const next = await encounter({
occurredAt: daysFromNow(6),
status: "PLANNED",
practitionerId: derm.id,
});
const prep = (await list()).upcoming.find(
(v) => v.id === next.id,
)?.preparation;
const to = berlinDay(next.occurredAt);
expect(prep).toMatchObject({
from: shiftDateKey(to, -(REPORT_MAX_RANGE_DAYS - 1)),
to,
basis: "practitioner",
capped: true,
});
// The anchor is still named: the window is measured from it.
expect(prep?.since?.id).toBe(anchor.id);
expect(daysBetweenDateKeys(prep!.from, to) + 1).toBe(REPORT_MAX_RANGE_DAYS);
// The report the panel requests for that window, cut the way it cuts
// it: from the first instant of `from` to the last of `to`. The export
// takes it whole instead of falling back to its 90-day default.
const startDate = startOfLocalDayKey(prep!.from, "Europe/Berlin");
const endDate = new Date(
startOfLocalDayKey(shiftDateKey(to, 1), "Europe/Berlin").getTime() - 1,
);
const range = normaliseDateRange({
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
});
expect(range.start.toISOString()).toBe(startDate.toISOString());
expect(range.end.toISOString()).toBe(endDate.toISOString());
expect(range.days).toBe(REPORT_MAX_RANGE_DAYS);
});
it("leaves a window that fits a report as it is", async () => {
const derm = await practitioner("Skin practice", "Dermatology");
const next = await encounter({
occurredAt: daysFromNow(6),
status: "PLANNED",
practitionerId: derm.id,
});
const to = berlinDay(next.occurredAt);
// Exactly the longest report, both end days counted.
const anchorDay = shiftDateKey(to, -(REPORT_MAX_RANGE_DAYS - 1));
await encounter({
occurredAt: new Date(
startOfLocalDayKey(anchorDay, "Europe/Berlin").getTime() +
10 * 60 * 60 * 1000,
),
practitionerId: derm.id,
});
const prep = (await list()).upcoming.find(
(v) => v.id === next.id,
)?.preparation;
expect(prep).toMatchObject({ from: anchorDay, capped: false });
});
it("leaves a cancelled upcoming visit without a window", async () => {
const cancelled = await encounter({
occurredAt: daysFromNow(5),