From da3d29424dee945e2f27c438994bd2ced04050ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 5 Nov 2026 13:57:13 +0100 Subject: [PATCH] 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. --- CHANGELOG.md | 9 +++ docs/api/openapi.yaml | 9 ++- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/ko.json | 4 +- messages/nl.json | 4 +- messages/pl.json | 4 +- messages/pt-BR.json | 4 +- messages/ru.json | 4 +- .../__tests__/visits-section.test.tsx | 2 + src/components/encounters/preparation-text.ts | 13 +++- .../__tests__/report-visit-offer.test.tsx | 29 ++++++++ src/lib/__tests__/doctor-report-data.test.ts | 22 +++++- src/lib/doctor-report-helpers.ts | 40 +++++++--- src/lib/doctor-report-range.ts | 10 +++ .../encounters/__tests__/preparation.test.ts | 33 +++++++++ src/lib/encounters/preparation.ts | 27 ++++++- src/lib/openapi/routes/encounters.ts | 7 +- .../integration/encounter-preparation.test.ts | 73 ++++++++++++++++++- 22 files changed, 282 insertions(+), 32 deletions(-) create mode 100644 src/lib/doctor-report-range.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 775fa162b..63e53a9ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 8a90d5d6a..c8a0b86f8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/messages/de.json b/messages/de.json index 88c823888..4ab05dd39 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 211d1db22..d2120bfd1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index f5fb1d686..4c7b923dd 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 0925ac845..4d73292b7 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index a878cc00f..f479729da 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/ko.json b/messages/ko.json index bcb8a8241..91c7e58d2 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -13339,7 +13339,9 @@ "specialty": "{date} 마지막 {specialty} 방문 이후", "kind": "{date} 같은 유형({kind})의 마지막 방문 이후", "default": "최근 {days}일", - "defaultReason": "같은 병원, 진료과 또는 유형의 이전 방문이 없어요." + "defaultReason": "같은 병원, 진료과 또는 유형의 이전 방문이 없어요.", + "capped": "최근 2년", + "cappedReason": "마지막으로 일치하는 진료가 2년 이상 지났으며, 보고서는 최대 2년까지 다룹니다." } }, "checkups": { diff --git a/messages/nl.json b/messages/nl.json index 6d81c2b6e..d0a7df7f3 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index b5d67a7dc..fe6c9bc6a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 22ec53245..1a7643b7f 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -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": { diff --git a/messages/ru.json b/messages/ru.json index e269253d1..4c4271bf6 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -13339,7 +13339,9 @@ "specialty": "С вашего последнего визита к специалисту «{specialty}» {date}", "kind": "С вашего последнего визита того же вида ({kind}) {date}", "default": "Последние {days} дней", - "defaultReason": "Нет более раннего визита с той же клиникой, специальностью или видом." + "defaultReason": "Нет более раннего визита с той же клиникой, специальностью или видом.", + "capped": "Последние два года", + "cappedReason": "Последний подходящий визит был более двух лет назад, а отчёт охватывает не больше двух лет." } }, "checkups": { diff --git a/src/components/encounters/__tests__/visits-section.test.tsx b/src/components/encounters/__tests__/visits-section.test.tsx index 03c909419..0172b6ab2 100644 --- a/src/components/encounters/__tests__/visits-section.test.tsx +++ b/src/components/encounters/__tests__/visits-section.test.tsx @@ -197,6 +197,7 @@ describe(" 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(" since the last visit", () => { to: "2026-12-01", basis: "default", since: null, + capped: false, }, }), ], diff --git a/src/components/encounters/preparation-text.ts b/src/components/encounters/preparation-text.ts index 3d5211228..a40aa3117 100644 --- a/src/components/encounters/preparation-text.ts +++ b/src/components/encounters/preparation-text.ts @@ -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 { diff --git a/src/components/settings/report-selection/__tests__/report-visit-offer.test.tsx b/src/components/settings/report-selection/__tests__/report-visit-offer.test.tsx index d61fd0847..54b9146e3 100644 --- a/src/components/settings/report-selection/__tests__/report-visit-offer.test.tsx +++ b/src/components/settings/report-selection/__tests__/report-visit-offer.test.tsx @@ -127,6 +127,7 @@ describe("", () => { practitionerName: "Former skin practice", specialty: "Dermatology", }, + capped: false, }, }), ]); @@ -144,6 +145,7 @@ describe("", () => { to: "2026-12-01", basis: "default", since: null, + capped: false, }, }), ]); @@ -153,6 +155,31 @@ describe("", () => { ); }); + 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("", () => { to: "2026-12-01", basis: "default", since: null, + capped: false, }, }), "Europe/Berlin", @@ -188,6 +216,7 @@ describe("", () => { to: "2026-11-12", basis: "default", since: null, + capped: false, }, }); expect(visitReportWindow(early, "America/New_York")).toEqual({ diff --git a/src/lib/__tests__/doctor-report-data.test.ts b/src/lib/__tests__/doctor-report-data.test.ts index 2d656fd0a..28126cae5 100644 --- a/src/lib/__tests__/doctor-report-data.test.ts +++ b/src/lib/__tests__/doctor-report-data.test.ts @@ -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", () => { diff --git a/src/lib/doctor-report-helpers.ts b/src/lib/doctor-report-helpers.ts index 096664de3..cb5393307 100644 --- a/src/lib/doctor-report-helpers.ts +++ b/src/lib/doctor-report-helpers.ts @@ -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, diff --git a/src/lib/doctor-report-range.ts b/src/lib/doctor-report-range.ts new file mode 100644 index 000000000..46df9dffa --- /dev/null +++ b/src/lib/doctor-report-range.ts @@ -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; diff --git a/src/lib/encounters/__tests__/preparation.test.ts b/src/lib/encounters/__tests__/preparation.test.ts index 31a0dfa72..0dae273fb 100644 --- a/src/lib/encounters/__tests__/preparation.test.ts +++ b/src/lib/encounters/__tests__/preparation.test.ts @@ -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", diff --git a/src/lib/encounters/preparation.ts b/src/lib/encounters/preparation.ts index 73e0de1c1..46cb66702 100644 --- a/src/lib/encounters/preparation.ts +++ b/src/lib/encounters/preparation.ts @@ -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, }; } diff --git a/src/lib/openapi/routes/encounters.ts b/src/lib/openapi/routes/encounters.ts index b3f7f97db..987128c40 100644 --- a/src/lib/openapi/routes/encounters.ts +++ b/src/lib/openapi/routes/encounters.ts @@ -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", diff --git a/tests/integration/encounter-preparation.test.ts b/tests/integration/encounter-preparation.test.ts index a269c1f72..260c9b229 100644 --- a/tests/integration/encounter-preparation.test.ts +++ b/tests/integration/encounter-preparation.test.ts @@ -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),