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