fix(visits): start the visit preparation and its report at the matching visit

Both surfaces read the start of their window from `preparation` and say
why it starts there: "Since your last visit with <practice> on <date>",
"Since your last <specialty> visit on <date>", "Since your last visit
of the same kind (<kind>) on <date>", or "The last 90 days" with the
reason that no earlier visit matched. The visits tab no longer picks
the last visit of any kind from the past list.

The report panel's appointment offer now sets both dates, the start
from the server and the end on the appointment's day, instead of the
end alone beside whatever start the panel held. Both stay editable.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 12:32:26 +02:00
1 parent 402cf1a35a
commit 99fec79177
15 files changed
+377 -86

No files matched your search

+46 -22
View File
@@ -17,10 +17,17 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { preparationText } from "@/components/encounters/preparation-text";
import type { EncounterDTO } from "@/lib/encounters/dto";
import type { EncounterPreparationDTO } from "@/lib/encounters/preparation";
import { apiGet } from "@/lib/api/api-fetch";
import type {
DateKey,
@@ -46,14 +53,20 @@ import { useTodayKey } from "./use-today-key";
/**
* "Since the last visit": the preparation the next planned appointment
* otherwise lacks. Dose changes, episodes that began, new results, extreme
* readings and gaps between the last visit and today, each dated, each date
* opening its day. Compiled from the record without any judgement; the
* wording is the day view's own.
* readings and gaps between the start of the window and today, each dated,
* each date opening its day. Compiled from the record without any judgement;
* the wording is the day view's own.
*
* One read, `GET /api/day/notable` for the window from the last visit to
* today: the server lists every context change in it (a dose change always,
* however quiet the day around it) and every notable observation. Nothing is
* recomputed here; this only spells the rows.
* The window starts where the server says (`EncounterDTO.preparation`): the
* last visit with the same practitioner, else the same specialty, else the
* same kind, else the last 90 days — never simply the last visit of any kind,
* which handed a dermatology appointment a week of an internist's data. The
* card's title says which, so the start date explains itself.
*
* One read, `GET /api/day/notable` for that window up to today: the server
* lists every context change in it (a dose change always, however quiet the
* day around it) and every notable observation. Nothing is recomputed here;
* this only spells the rows.
*
* The component owns its card and draws nothing until it has a row to show:
* not while the window loads, not when it holds nothing worth listing. A card
@@ -132,12 +145,12 @@ export function preparationRows(
export function SinceLastVisit({
next,
last,
preparation,
}: {
/** The next planned visit, the one this prepares. */
next: EncounterDTO;
/** The last visit that took place. */
last: EncounterDTO;
/** The window before it, as the server resolved it. */
preparation: EncounterPreparationDTO;
}) {
const { t, tCount } = useTranslations();
const fmt = useFormatters();
@@ -147,16 +160,17 @@ export function SinceLastVisit({
const notableText = useNotableText();
const [showAll, setShowAll] = useState(false);
const lastDay = dateKeyOfInstant(last.occurredAt, timeZone);
// From the last visit's own day (a dose changed at the visit belongs to
// what the next one should know) to today. The server caps the span.
// From the server's start day (the matched visit's own day: a dose changed
// at that visit belongs to what the next one should know) to today. The
// notable read caps the span.
const fromDay: DateKey = preparation.from;
const window = useQuery({
queryKey: queryKeys.dayNotable(lastDay, today),
queryKey: queryKeys.dayNotable(fromDay, today),
queryFn: () => {
const params = new URLSearchParams({ from: lastDay, to: today });
const params = new URLSearchParams({ from: fromDay, to: today });
return apiGet<DayNotableWindowResponse>(`/api/day/notable?${params}`);
},
enabled: lastDay <= today,
enabled: fromDay <= today,
staleTime: 5 * 60_000,
});
@@ -194,7 +208,15 @@ export function SinceLastVisit({
})
: [];
const lastLabel = fmt.dateShortSmartCalendar(lastDay);
const fromLabel = fmt.dateShortSmartCalendar(fromDay);
// Where the window starts and why, in one spelling shared with the report
// panel's offer.
const heading = preparationText(t, fmt.dateShortSmartCalendar, preparation);
const reason = heading.reason ? (
<CardDescription className="text-xs" data-slot="since-last-visit-reason">
{heading.reason}
</CardDescription>
) : null;
// A failed read keeps the card and its title, so the page does not jump
// and the reader still sees what could not be loaded.
@@ -206,8 +228,9 @@ export function SinceLastVisit({
size="sm"
icon={Stethoscope}
titleAs="h3"
title={t("day.sinceLastVisit.title", { date: lastLabel })}
title={heading.title}
/>
{reason}
</CardHeader>
<CardContent>
<QueryErrorRow
@@ -236,13 +259,14 @@ export function SinceLastVisit({
icon={Stethoscope}
id={`since-last-visit-${next.id}`}
titleAs="h3"
title={t("day.sinceLastVisit.title", { date: lastLabel })}
title={heading.title}
right={
<span className="text-muted-foreground text-xs">
{tCount("day.sinceLastVisit.count", rows.length)}
</span>
}
/>
{reason}
</CardHeader>
<CardContent>
<section
@@ -297,11 +321,11 @@ export function SinceLastVisit({
className="min-h-11 sm:min-h-9"
>
<Link
href={`/settings/gesundheitsakte?reportFrom=${lastDay}&reportTo=${nextDay < today ? nextDay : today}`}
href={`/settings/gesundheitsakte?reportFrom=${fromDay}&reportTo=${nextDay < today ? nextDay : today}`}
data-slot="since-last-visit-report"
>
<FileText className="size-4" aria-hidden="true" />
{t("day.sinceLastVisit.report", { date: lastLabel })}
{t("day.sinceLastVisit.report", { date: fromLabel })}
</Link>
</Button>
</section>
@@ -47,7 +47,7 @@ vi.mock("@/lib/api/api-fetch", async () => {
};
});
import { dateKeyOfInstant, todayKeyInZone } from "@/components/day/day-url";
import { todayKeyInZone } from "@/components/day/day-url";
import { ApiError } from "@/lib/api/api-fetch";
import type { DayNotableWindowResponse } from "@/lib/day/contract";
import { I18nProvider } from "@/lib/i18n/context";
@@ -174,13 +174,32 @@ describe("<VisitsSection>", () => {
* to wrap it in a card, so whenever it had nothing to list (still loading, a
* quiet window, a failed read) an empty bordered card sat under the visit.
* It now draws its own card, and only once it has a row.
*
* The window is the server's (`preparation` on the booked visit). The section
* used to start it at the last visit of any kind it found in the past list;
* the fixtures carry such a visit, newer than the window's start, and the
* read must not move to it.
*/
describe("<VisitsSection> since the last visit", () => {
const sameDoctor = {
id: "derm-before",
occurredAt: "2026-03-02T09:00:00.000Z",
kind: "SPECIALIST" as const,
practitionerName: "Skin practice",
specialty: "Dermatology",
};
const next = visit({
id: "next",
status: "PLANNED",
occurredAt: "2026-09-20T09:00:00.000Z",
preparation: {
from: "2026-03-02",
basis: "practitioner",
since: sameDoctor,
},
});
const unprepared = visit({ ...next, preparation: null });
// A newer visit elsewhere: what the old client rule anchored on.
const last = visit({ id: "last", occurredAt: "2026-08-01T09:00:00.000Z" });
/** Cards painted inside the upcoming list. */
@@ -194,12 +213,8 @@ describe("<VisitsSection> since the last visit", () => {
function seedWindow(window: DayNotableWindowResponse | null) {
return (queryClient: QueryClient) => {
if (!window) return;
const tz = DEFAULT_TIMEZONE;
queryClient.setQueryData(
queryKeys.dayNotable(
dateKeyOfInstant(last.occurredAt, tz),
todayKeyInZone(tz),
),
queryKeys.dayNotable("2026-03-02", todayKeyInZone(DEFAULT_TIMEZONE)),
window,
);
};
@@ -219,10 +234,10 @@ describe("<VisitsSection> since the last visit", () => {
});
it("draws no card while the window is still loading", () => {
const withLast = render({ upcoming: [next], past: [last] });
const withoutLast = render({ upcoming: [next], past: [] });
expect(withLast).not.toContain('data-slot="since-last-visit"');
expect(upcomingCards(withLast)).toBe(upcomingCards(withoutLast));
const loading = render({ upcoming: [next], past: [last] });
const without = render({ upcoming: [unprepared], past: [last] });
expect(loading).not.toContain('data-slot="since-last-visit"');
expect(upcomingCards(loading)).toBe(upcomingCards(without));
});
it("draws no card when the window holds nothing worth listing", () => {
@@ -230,17 +245,16 @@ describe("<VisitsSection> since the last visit", () => {
{ upcoming: [next], past: [last] },
seedWindow(emptyWindow),
);
const baseline = render({ upcoming: [next], past: [] });
const baseline = render({ upcoming: [unprepared], past: [last] });
expect(html).not.toContain('data-slot="since-last-visit"');
expect(upcomingCards(html)).toBe(upcomingCards(baseline));
});
function seedFailure(error: Error) {
return (queryClient: QueryClient) => {
const tz = DEFAULT_TIMEZONE;
const queryKey = queryKeys.dayNotable(
dateKeyOfInstant(last.occurredAt, tz),
todayKeyInZone(tz),
"2026-03-02",
todayKeyInZone(DEFAULT_TIMEZONE),
);
// The state the section sees once the real read has given up: a mount
// does not retry it.
@@ -257,14 +271,14 @@ describe("<VisitsSection> since the last visit", () => {
{ upcoming: [next], past: [last] },
seedFailure(new ApiError("window read failed", 500)),
);
const baseline = render({ upcoming: [next], past: [] });
const baseline = render({ upcoming: [unprepared], past: [last] });
expect(html).toContain('data-slot="since-last-visit-error"');
expect(html).toContain('role="alert"');
expect(html).not.toContain('data-slot="since-last-visit"');
// The failed window keeps its own card and title, so the page does not
// jump; the visit cards beside it stay as they were.
expect(upcomingCards(html)).toBe(upcomingCards(baseline) + 1);
expect(html).toContain("Since the last visit on");
expect(html).toContain("Since your last visit with Skin practice on");
});
// Visits need the profile section, the window reads measurements, and a
@@ -296,10 +310,15 @@ describe("<VisitsSection> since the last visit", () => {
],
} as unknown as DayNotableWindowResponse),
);
const baseline = render({ upcoming: [next], past: [] });
const baseline = render({ upcoming: [unprepared], past: [last] });
expect(html).toContain('data-slot="since-last-visit"');
expect(html).toContain('data-slot="since-last-visit-row"');
expect(upcomingCards(html)).toBe(upcomingCards(baseline) + 1);
// The title says where the window starts and why; the report starts
// there too, not at the newer visit elsewhere.
expect(html).toContain("Since your last visit with Skin practice on");
expect(html).toContain("reportFrom=2026-03-02&amp;");
expect(html).not.toContain('data-slot="since-last-visit-reason"');
// The card heads like every compact card: TileHeader at the small size,
// a real heading under the section's own, at the compact density.
const card = html.slice(
@@ -314,4 +333,37 @@ describe("<VisitsSection> since the last visit", () => {
/<h3 id="since-last-visit-[^"]+" data-slot="card-title" class="[^"]*text-sm/,
);
});
it("says why on the default window, where the title alone cannot", () => {
const html = render(
{
upcoming: [
visit({
...next,
preparation: { from: "2026-03-02", basis: "default", since: null },
}),
],
past: [last],
},
seedWindow({
...emptyWindow,
changes: [
{
date: "2026-08-03",
kind: "doseChange",
section: "medications",
id: "d1",
title: "Example 5 mg",
count: 1,
href: null,
},
],
} as unknown as DayNotableWindowResponse),
);
expect(html).toContain("The last 90 days");
expect(html).toContain('data-slot="since-last-visit-reason"');
expect(html).toContain(
"No earlier visit with the same practice, specialty or kind.",
);
});
});
@@ -0,0 +1,69 @@
/**
* The window before a booked visit, in words: the line that says where the
* visit preparation and the offered doctor report start, and why there.
*
* Spells only what the server resolved (`EncounterDTO.preparation`); nothing
* here chooses a date. One spelling for both surfaces that show it, so the
* visits tab and the report panel cannot give two reasons for one window.
*/
import type { EncounterKind } from "@/generated/prisma/client";
import {
PREPARATION_DEFAULT_DAYS,
type EncounterPreparationDTO,
} from "@/lib/encounters/preparation";
import { encounterKindText } from "./encounter-labels";
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
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. */
reason: string | null;
}
export function preparationText(
t: Translate,
formatDay: (day: string) => string,
preparation: EncounterPreparationDTO,
): PreparationText {
const date = formatDay(preparation.from);
const since = preparation.since;
switch (preparation.basis) {
case "practitioner":
return {
title: t("encounters.preparation.practitioner", {
name: since?.practitionerName ?? "",
date,
}),
reason: null,
};
case "specialty":
return {
title: t("encounters.preparation.specialty", {
specialty: since?.specialty ?? "",
date,
}),
reason: null,
};
case "kind":
return {
title: t("encounters.preparation.kind", {
kind: since ? encounterKindText(t, since.kind as EncounterKind) : "",
date,
}),
reason: null,
};
case "default":
return {
title: t("encounters.preparation.default", {
days: PREPARATION_DEFAULT_DAYS,
}),
reason: t("encounters.preparation.defaultReason"),
};
}
}
+15 -9
View File
@@ -72,8 +72,11 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
resolve: fetchVisit,
});
const empty = upcoming.length === 0 && past.length === 0;
// The last visit that took place: the start of the visit preparation.
const lastDone = past.find((encounter) => encounter.status === "DONE");
// The next booked visit carries the window before it, resolved by the
// server (`EncounterDTO.preparation`): which earlier visit it starts at and
// why. Never picked here from the past list, which would be the last visit
// of any kind — the rule the server replaced.
const prepared = upcoming.find((encounter) => encounter.preparation);
const addButton = canAddVisit ? (
<Button
@@ -135,18 +138,21 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
icon={CalendarClock}
title={t("encounters.upcomingHeading")}
/>
{upcoming.map((encounter, i) => (
{upcoming.map((encounter) => (
<Fragment key={encounter.id}>
<VisitCard
encounter={encounter}
onOpen={canManageVisits ? openSheet : undefined}
/>
{/* v1.42 — the next appointment carries what happened
since the last one, each date opening its day. The
component draws its own card, and none when it has
nothing to list. */}
{i === 0 && lastDone ? (
<SinceLastVisit next={encounter} last={lastDone} />
{/* v1.42 — the next booked appointment carries what
happened in the window before it, each date opening its
day. The component draws its own card, and none when it
has nothing to list. */}
{encounter.id === prepared?.id && prepared.preparation ? (
<SinceLastVisit
next={encounter}
preparation={prepared.preparation}
/>
) : null}
</Fragment>
))}
@@ -32,7 +32,7 @@ vi.mock("@/lib/api/api-fetch", async () => {
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { EncounterDTO } from "@/lib/encounters/dto";
import { ReportVisitOffer } from "../report-visit-offer";
import { ReportVisitOffer, visitReportWindow } from "../report-visit-offer";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -67,7 +67,7 @@ function render(upcoming: EncounterDTO[], onUse = () => {}) {
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<ReportVisitOffer onUseVisitDate={onUse} />
<ReportVisitOffer onUseVisitWindow={onUse} />
</I18nProvider>
</QueryClientProvider>,
);
@@ -108,4 +108,57 @@ describe("<ReportVisitOffer>", () => {
expect(html).toContain('data-slot="report-visit-offer-apply"');
expect(onUse).not.toHaveBeenCalled();
});
// The issue behind this: an internist a week ago, a dermatologist next. The
// start used to be whatever the panel held, typically the last visit of any
// kind; it is now the server's window before this visit, and says why.
it("names where the report would start, and why there", () => {
const html = render([
visit({
id: "derm",
preparation: {
from: "2026-03-02",
basis: "specialty",
since: {
id: "old-derm",
occurredAt: "2026-03-02T09:00:00.000Z",
kind: "SPECIALIST",
practitionerName: "Former skin practice",
specialty: "Dermatology",
},
},
}),
]);
expect(html).toContain('data-slot="report-visit-offer-window"');
expect(html).toContain("Since your last Dermatology visit on");
expect(html).toContain("Use this window");
});
it("says why on the default window", () => {
const html = render([
visit({
id: "first",
preparation: { from: "2026-07-23", basis: "default", since: null },
}),
]);
expect(html).toContain("The last 90 days");
expect(html).toContain(
"No earlier visit with the same practice, specialty or kind.",
);
});
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(
visit({
id: "derm",
occurredAt: at.toISOString(),
preparation: { from: "2026-03-02", basis: "default", since: null },
}),
);
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();
});
});
@@ -14,15 +14,20 @@
* Termin ansteht") and does nothing until pressed. A control that moves on its
* own is the thing that makes an export panel untrustworthy.
*
* Nothing about the stored selection changes: this sets the custom-range end
* date and nothing else.
* Pressing it sets both ends of the custom range: the end on the appointment's
* day, the start where the server put the window before that visit
* (`EncounterDTO.preparation`: the last visit with the same practitioner, else
* the same specialty, else the same kind, else the last 90 days). The offer
* says which, so the start date explains itself, and both dates stay editable.
* Nothing about the stored selection changes.
*/
import { CalendarClock, X } from "lucide-react";
import { useState } from "react";
import { encounterKindText } from "@/components/encounters/encounter-labels";
import { preparationText } from "@/components/encounters/preparation-text";
import { Button } from "@/components/ui/button";
import { useEncounters } from "@/hooks/use-encounters";
import { useEncounters, type Encounter } from "@/hooks/use-encounters";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { EncounterKind } from "@/generated/prisma/client";
import { detectBrowserTimezone, userDayKey } from "@/lib/tz/format";
@@ -40,11 +45,27 @@ 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. */
from: string | null;
/** The appointment's own day. */
to: string;
}
/** What pressing the offer for `visit` applies. */
export function visitReportWindow(visit: Encounter): VisitReportWindow {
return {
from: visit.preparation?.from ?? null,
to: localDay(visit.occurredAt),
};
}
export function ReportVisitOffer({
onUseVisitDate,
onUseVisitWindow,
}: {
/** Sets the custom window's END date. Called only on an explicit press. */
onUseVisitDate: (endDay: string) => void;
/** Sets the custom window. Called only on an explicit press. */
onUseVisitWindow: (window: VisitReportWindow) => void;
}) {
const { t } = useTranslations();
const format = useFormatters();
@@ -68,6 +89,9 @@ export function ReportVisitOffer({
const what =
next.practitioner?.name ?? encounterKindText(t, next.kind as EncounterKind);
const windowText = next.preparation
? preparationText(t, format.dateShortSmartCalendar, next.preparation)
: null;
return (
<div
@@ -78,24 +102,38 @@ export function ReportVisitOffer({
a phone the sentence takes the full row and the two buttons wrap
below it; with a zero basis it was squeezed into a one-word column
beside them. */}
<p className="text-foreground flex min-w-0 flex-1 basis-full items-center gap-2 text-sm sm:basis-0">
<CalendarClock className="size-4 shrink-0" aria-hidden />
<span>
{t("settings.healthRecord.visitOffer", {
what,
when: format.date(next.occurredAt),
})}
</span>
</p>
<div className="text-foreground flex min-w-0 flex-1 basis-full items-start gap-2 text-sm sm:basis-0">
<CalendarClock className="mt-0.5 size-4 shrink-0" aria-hidden />
<div className="min-w-0 space-y-0.5">
<p>
{t("settings.healthRecord.visitOffer", {
what,
when: format.date(next.occurredAt),
})}
</p>
{/* Where the report would start, and why there: meta, so muted. */}
{windowText ? (
<p
className="text-muted-foreground text-xs"
data-slot="report-visit-offer-window"
>
{windowText.title}
</p>
) : null}
{windowText?.reason ? (
<p className="text-muted-foreground text-xs">{windowText.reason}</p>
) : null}
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="ml-auto min-h-11 sm:ml-0 sm:min-h-9"
data-slot="report-visit-offer-apply"
onClick={() => onUseVisitDate(localDay(next.occurredAt))}
onClick={() => onUseVisitWindow(visitReportWindow(next))}
>
{t("settings.healthRecord.visitOfferApply")}
{t("settings.healthRecord.visitOfferApplyWindow")}
</Button>
<Button
type="button"
@@ -322,11 +322,12 @@ export function HealthRecordExportPanel() {
</div>
{/* Never auto-applies: pressing it is what opens the custom window and
sets its end date. */}
sets both its dates, which stay editable. */}
<ReportVisitOffer
onUseVisitDate={(endDay) => {
onUseVisitWindow={({ from, to }) => {
setCustomRange(true);
setEndDate(endDay);
if (from) setStartDate(from);
setEndDate(to);
}}
/>