fix(report): cut the visit offer's end day on the record's calendar

The offer took its start from the server's preparation window, cut on
the record's calendar, and its end from the appointment's day in the
browser's zone. In a browser in another zone a late-evening or
early-morning visit moved the end a day against the start. Both ends now
use the record's zone.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 15:07:05 +02:00
1 parent 03cacc996c
commit 88200a9273
2 files changed
+37 -12

No files matched your search

@@ -155,10 +155,26 @@ describe("<ReportVisitOffer>", () => {
occurredAt: at.toISOString(),
preparation: { from: "2026-03-02", basis: "default", since: null },
}),
"Europe/Berlin",
);
expect(window.from).toBe("2026-03-02");
expect(window.to).toMatch(/^\d{4}-\d{2}-\d{2}$/);
// Without a window from the server, only the end moves.
expect(visitReportWindow(visit({ id: "plain" })).from).toBeNull();
expect(
visitReportWindow(visit({ id: "plain" }), "Europe/Berlin").from,
).toBeNull();
});
it("cuts the appointment's day on the record's calendar, like the start", () => {
// 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.
const early = visit({
id: "early",
occurredAt: "2026-11-12T03:30:00.000Z",
preparation: { from: "2026-08-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");
});
});
@@ -28,9 +28,13 @@ import { encounterKindText } from "@/components/encounters/encounter-labels";
import { preparationText } from "@/components/encounters/preparation-text";
import { Button } from "@/components/ui/button";
import { useEncounters, type Encounter } from "@/hooks/use-encounters";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import {
useDisplayTimezone,
useFormatters,
useTranslations,
} from "@/lib/i18n/context";
import type { EncounterKind } from "@/generated/prisma/client";
import { detectBrowserTimezone, userDayKey } from "@/lib/tz/format";
import { userDayKey } from "@/lib/tz/format";
/**
* How far ahead a booked visit is treated as the reason for this report.
@@ -40,11 +44,6 @@ import { detectBrowserTimezone, userDayKey } from "@/lib/tz/format";
*/
export const REPORT_VISIT_HORIZON_DAYS = 14;
/** The local `yyyy-MM-dd` a `DateField` takes, in the reader's own day. */
function localDay(iso: string): string {
return userDayKey(new Date(iso), detectBrowserTimezone());
}
/** The report window an appointment offers, as `yyyy-MM-dd` days. */
export interface VisitReportWindow {
/** The server's start of the window before the visit; null if it has none. */
@@ -53,11 +52,20 @@ export interface VisitReportWindow {
to: string;
}
/** What pressing the offer for `visit` applies. */
export function visitReportWindow(visit: Encounter): 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.
*/
export function visitReportWindow(
visit: Encounter,
timeZone: string,
): VisitReportWindow {
return {
from: visit.preparation?.from ?? null,
to: localDay(visit.occurredAt),
to: userDayKey(new Date(visit.occurredAt), timeZone),
};
}
@@ -69,6 +77,7 @@ export function ReportVisitOffer({
}) {
const { t } = useTranslations();
const format = useFormatters();
const timeZone = useDisplayTimezone();
const [dismissed, setDismissed] = useState(false);
const visits = useEncounters();
@@ -131,7 +140,7 @@ export function ReportVisitOffer({
size="sm"
className="ml-auto min-h-11 sm:ml-0 sm:min-h-9"
data-slot="report-visit-offer-apply"
onClick={() => onUseVisitWindow(visitReportWindow(next))}
onClick={() => onUseVisitWindow(visitReportWindow(next, timeZone))}
>
{t("settings.healthRecord.visitOfferApplyWindow")}
</Button>