From a3f5f998cfc0bcd4d0f344a691fc1ddd559a5897 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 16:50:14 +0200 Subject: [PATCH] fix(report): end the visit offer on the record's day from the server The preparation window now carries to, the booked visit's own day on the record's calendar, and the report offer uses it. Cutting the end in the reader's zone was wrong for a guardian viewing a managed profile in another zone. --- docs/api/openapi.yaml | 5 +++ .../__tests__/visits-section.test.tsx | 8 +++- .../__tests__/report-visit-offer.test.tsx | 40 +++++++++++++++---- .../report-selection/report-visit-offer.tsx | 12 +++--- .../encounters/__tests__/preparation.test.ts | 17 ++++++++ src/lib/encounters/preparation.ts | 5 +++ src/lib/openapi/routes/encounters.ts | 5 +++ .../integration/encounter-preparation.test.ts | 2 + 8 files changed, 80 insertions(+), 14 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 06ede8f1e..9b4f13c5b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -72152,6 +72152,10 @@ components: 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`." + 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 + report window offered for the appointment ends here. basis: type: string enum: @@ -72169,6 +72173,7 @@ components: - type: "null" required: - from + - to - basis - since additionalProperties: false diff --git a/src/components/encounters/__tests__/visits-section.test.tsx b/src/components/encounters/__tests__/visits-section.test.tsx index 74531b746..03c909419 100644 --- a/src/components/encounters/__tests__/visits-section.test.tsx +++ b/src/components/encounters/__tests__/visits-section.test.tsx @@ -194,6 +194,7 @@ describe(" since the last visit", () => { occurredAt: "2026-09-20T09:00:00.000Z", preparation: { from: "2026-03-02", + to: "2026-12-01", basis: "practitioner", since: sameDoctor, }, @@ -340,7 +341,12 @@ describe(" since the last visit", () => { upcoming: [ visit({ ...next, - preparation: { from: "2026-03-02", basis: "default", since: null }, + preparation: { + from: "2026-03-02", + to: "2026-12-01", + basis: "default", + since: null, + }, }), ], past: [last], 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 68376be4d..d61fd0847 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 @@ -118,6 +118,7 @@ describe("", () => { id: "derm", preparation: { from: "2026-03-02", + to: "2026-12-01", basis: "specialty", since: { id: "old-derm", @@ -138,7 +139,12 @@ describe("", () => { const html = render([ visit({ id: "first", - preparation: { from: "2026-07-23", basis: "default", since: null }, + preparation: { + from: "2026-07-23", + to: "2026-12-01", + basis: "default", + since: null, + }, }), ]); expect(html).toContain("The last 90 days"); @@ -153,7 +159,12 @@ describe("", () => { visit({ id: "derm", occurredAt: at.toISOString(), - preparation: { from: "2026-03-02", basis: "default", since: null }, + preparation: { + from: "2026-03-02", + to: "2026-12-01", + basis: "default", + since: null, + }, }), "Europe/Berlin", ); @@ -165,16 +176,29 @@ describe("", () => { ).toBeNull(); }); - it("cuts the appointment's day on the record's calendar, like the start", () => { + it("ends on the record's day the server cut, not the reader's", () => { // 04:30 on the 12th in Berlin is still the evening of the 11th in New - // York. The start comes from the server on the record's calendar; the - // end is cut in the zone it is handed, never the browser's own. + // York. A guardian in New York viewing a Berlin record gets the record's + // day: both ends come from the server, on the record's calendar. const early = visit({ id: "early", occurredAt: "2026-11-12T03:30:00.000Z", - preparation: { from: "2026-08-12", basis: "default", since: null }, + preparation: { + from: "2026-08-12", + to: "2026-11-12", + basis: "default", + since: null, + }, }); - expect(visitReportWindow(early, "Europe/Berlin").to).toBe("2026-11-12"); - expect(visitReportWindow(early, "America/New_York").to).toBe("2026-11-11"); + expect(visitReportWindow(early, "America/New_York")).toEqual({ + from: "2026-08-12", + to: "2026-11-12", + }); + // Without a preparation the zone handed in decides. + const plain = visit({ + id: "plain", + occurredAt: "2026-11-12T03:30:00.000Z", + }); + expect(visitReportWindow(plain, "America/New_York").to).toBe("2026-11-11"); }); }); diff --git a/src/components/settings/report-selection/report-visit-offer.tsx b/src/components/settings/report-selection/report-visit-offer.tsx index 62806d0aa..156a8d4d7 100644 --- a/src/components/settings/report-selection/report-visit-offer.tsx +++ b/src/components/settings/report-selection/report-visit-offer.tsx @@ -54,10 +54,11 @@ export interface VisitReportWindow { /** * What pressing the offer for `visit` applies. Both ends are days of the - * record's own calendar (`timeZone`, the profile zone): the server cuts - * `preparation.from` there, and the appointment's day has to be cut in the - * same zone, or a browser in another zone moved the end a day against the - * start for a late-evening or early-morning visit. + * viewed record's own calendar, as the server cut them (`preparation.from` + * and `preparation.to`). Cutting the end in the browser's zone moved it a day + * against the start for a late-evening or early-morning visit, and a + * guardian viewing a managed profile would have cut it in their own zone. + * `timeZone` serves only a visit without a preparation. */ export function visitReportWindow( visit: Encounter, @@ -65,7 +66,8 @@ export function visitReportWindow( ): VisitReportWindow { return { from: visit.preparation?.from ?? null, - to: userDayKey(new Date(visit.occurredAt), timeZone), + to: + visit.preparation?.to ?? userDayKey(new Date(visit.occurredAt), timeZone), }; } diff --git a/src/lib/encounters/__tests__/preparation.test.ts b/src/lib/encounters/__tests__/preparation.test.ts index b219ef8b1..31a0dfa72 100644 --- a/src/lib/encounters/__tests__/preparation.test.ts +++ b/src/lib/encounters/__tests__/preparation.test.ts @@ -67,6 +67,7 @@ describe("the window before a booked visit", () => { ]); expect(prep).toEqual({ from: "2026-03-02", + to: "2026-10-23", basis: "practitioner", since: { id: "derm", @@ -99,6 +100,7 @@ describe("the window before a booked visit", () => { const prep = resolve(next, [internist("int", "2026-10-16T09:00:00.000Z")]); expect(prep).toEqual({ from: "2026-07-23", + to: "2026-10-23", basis: "default", since: null, }); @@ -164,6 +166,21 @@ describe("the window before a booked visit", () => { ]); expect(prep.from).toBe("2026-05-05"); }); + + it("ends on the booked visit's own day in the record's zone, whatever the rule", () => { + // 23:30 UTC on the 22nd is the 23rd in Berlin. + const late = visit("late-next", "2026-10-22T23:30:00.000Z", { + practitionerId: "p-derm", + }); + expect(resolve(late, []).to).toBe("2026-10-23"); + expect( + resolve(late, [ + visit("before", "2026-05-04T08:00:00.000Z", { + practitionerId: "p-derm", + }), + ]).to, + ).toBe("2026-10-23"); + }); }); describe("specialty comparison", () => { diff --git a/src/lib/encounters/preparation.ts b/src/lib/encounters/preparation.ts index d5dfb03a5..73e0de1c1 100644 --- a/src/lib/encounters/preparation.ts +++ b/src/lib/encounters/preparation.ts @@ -68,6 +68,8 @@ export interface PreparationAnchorDTO { export interface EncounterPreparationDTO { /** First day of the window, `YYYY-MM-DD` on the record's own calendar. */ from: string; + /** The booked visit's own day, on the same calendar as `from`. */ + to: string; /** Which rule chose `from`. */ basis: PreparationBasis; /** The visit `from` is the day of; null on `default`. */ @@ -107,6 +109,7 @@ export function resolvePreparation(args: { today: string; }): EncounterPreparationDTO { const { next, tz, today } = args; + const to = userDayKey(next.occurredAt, tz); const earlier = args.done.filter( (visit) => visit.id !== next.id && @@ -139,6 +142,7 @@ export function resolvePreparation(args: { if (!hit) continue; return { from: userDayKey(hit.occurredAt, tz), + to, basis, since: { id: hit.id, @@ -151,6 +155,7 @@ export function resolvePreparation(args: { } return { from: shiftDateKey(today, -PREPARATION_DEFAULT_DAYS), + to, basis: "default", since: null, }; diff --git a/src/lib/openapi/routes/encounters.ts b/src/lib/openapi/routes/encounters.ts index d00485b32..b3f7f97db 100644 --- a/src/lib/openapi/routes/encounters.ts +++ b/src/lib/openapi/routes/encounters.ts @@ -191,6 +191,11 @@ const encounterPreparation = z .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`.", ), + to: z + .string() + .describe( + "The booked visit's own day, `YYYY-MM-DD` on the same calendar as `from` (the record's, not the reader's). A report window offered for the appointment ends here.", + ), basis: z .enum(PREPARATION_BASES) .describe( diff --git a/tests/integration/encounter-preparation.test.ts b/tests/integration/encounter-preparation.test.ts index 6d9f00023..02c6a628e 100644 --- a/tests/integration/encounter-preparation.test.ts +++ b/tests/integration/encounter-preparation.test.ts @@ -142,6 +142,7 @@ describe("GET /api/encounters — the window before a booked visit", () => { const booked = body.upcoming.find((v) => v.id === next.id); expect(booked?.preparation).toEqual({ from: berlinDay(dermVisit.occurredAt), + to: berlinDay(next.occurredAt), basis: "practitioner", since: { id: dermVisit.id, @@ -207,6 +208,7 @@ describe("GET /api/encounters — the window before a booked visit", () => { const booked = (await list()).upcoming.find((v) => v.id === next.id); expect(booked?.preparation).toEqual({ from: shiftDateKey(berlinDay(new Date()), -90), + to: berlinDay(next.occurredAt), basis: "default", since: null, });