Merge remote-tracking branch 'origin/main' into fix/settings-ekg-polish

This commit is contained in:
Marc-André Bombeck committed 2026-08-08 16:17:40 +02:00
commit 4c2dfa57b4
138 files changed
+12046 -137

No files matched your search

@@ -44,6 +44,10 @@ vi.mock("@/hooks/use-auth", async () => {
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
// The panel's visit offer reads the encounter list. Pending forever here:
// this file is about the panel's own state, and a resolved read would put
// an offer in the markup every assertion below would have to know about.
useQuery: () => ({ data: undefined, isPending: true, isError: false }),
}));
import { I18nProvider } from "@/lib/i18n/context";
@@ -188,7 +192,8 @@ describe("<HealthRecordExportPanel> — the first run", () => {
expect(groupChip(html, "body")).toBe("0/12");
expect(groupChip(html, "labs")).toBe("0/1");
expect(groupChip(html, "medications")).toBe("0/4");
expect(groupChip(html, "history")).toBe("0/2");
// ALLERGIES, ILLNESS_EPISODES and now VISITS.
expect(groupChip(html, "history")).toBe("0/3");
expect(groupChip(html, "cardio")).toBe("0/14");
expect(groupChip(html, "activity")).toBe("0/8");
expect(groupChip(html, "sleepRecovery")).toBe("0/16");
@@ -187,6 +187,7 @@ const HERO_ITEM_LABEL_KEYS: Record<PriorityItemKind, string> = {
ecg_new_recording: "dashboard.heroItems.ecg_new_recording",
tension_window: "dashboard.heroItems.tension_window",
same_time_baseline: "dashboard.heroItems.same_time_baseline",
upcoming_visit: "dashboard.heroItems.upcoming_visit",
};
export function DashboardLayoutSection({ id }: { id: string }) {
@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
/**
* The report's reason, pinned on the two properties that make it trustworthy:
*
* - it appears for an appointment inside the horizon and for nothing else;
* - it NEVER auto-applies. Applying is a press, and the component calls
* nothing on render — a window control that moves on its own is what makes
* an export panel untrustworthy.
*
* The fixtures are relative to the moment the test runs, so the horizon cannot
* rot out from under them as the calendar moves.
*/
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", modules: {} },
isLoading: false,
isAuthenticated: true,
}),
}));
vi.mock("@/lib/api/api-fetch", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
"@/lib/api/api-fetch",
);
return { ...actual, apiGet: () => new Promise(() => {}) };
});
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";
const DAY_MS = 24 * 60 * 60 * 1000;
function visit(over: Partial<EncounterDTO> & { id: string }): EncounterDTO {
return {
occurredAt: new Date(Date.now() + 5 * DAY_MS).toISOString(),
status: "PLANNED",
kind: "SPECIALIST",
practitioner: null,
reason: null,
outcome: null,
reminderNextDueAt: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...over,
};
}
function render(upcoming: EncounterDTO[], onUse = () => {}) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClient.setQueryData(
queryKeys.encounterList(null, null, undefined, undefined),
{
upcoming,
past: [],
},
);
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<ReportVisitOffer onUseVisitDate={onUse} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<ReportVisitOffer>", () => {
it("offers an appointment five days out, naming its own source", () => {
const html = render([visit({ id: "soon" })]);
expect(html).toContain('data-slot="report-visit-offer"');
expect(html).toContain("An appointment is coming up");
expect(html).toContain('data-slot="report-visit-offer-apply"');
});
it("stays silent for an appointment thirty days out", () => {
const html = render([
visit({
id: "far",
occurredAt: new Date(Date.now() + 30 * DAY_MS).toISOString(),
}),
]);
expect(html).not.toContain('data-slot="report-visit-offer"');
});
it("stays silent when there is no appointment at all", () => {
expect(render([])).not.toContain('data-slot="report-visit-offer"');
});
it("ignores a cancelled appointment inside the horizon", () => {
const html = render([visit({ id: "off", status: "CANCELLED" })]);
expect(html).not.toContain('data-slot="report-visit-offer"');
});
it("changes the window only when pressed", () => {
const onUse = vi.fn();
const html = render([visit({ id: "soon" })], onUse);
// The offer rendered — so the assertion below is about restraint, not
// about a component that produced nothing.
expect(html).toContain('data-slot="report-visit-offer-apply"');
expect(onUse).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,110 @@
"use client";
/**
* "Bericht für den Termin am … erstellen" — the report knows why it is being
* generated.
*
* The cheapest integration in this milestone and the one that changes the most:
* a person opening the export panel a week before an appointment is almost
* always opening it BECAUSE of the appointment, and the window they want ends
* on that day. One query, one prefilled field, one dismissible offer.
*
* **It never auto-applies.** The window is not silently changed and then left
* for the person to discover; the offer names its own source ("weil am … ein
* 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.
*/
import { CalendarClock, X } from "lucide-react";
import { useState } from "react";
import { encounterKindText } from "@/components/encounters/encounter-labels";
import { Button } from "@/components/ui/button";
import { useEncounters } from "@/hooks/use-encounters";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { EncounterKind } from "@/generated/prisma/client";
/**
* How far ahead a booked visit is treated as the reason for this report.
*
* Two weeks: far enough that somebody preparing in advance is offered it, near
* enough that a routine check-up six months out does not colonise the panel.
*/
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 {
const at = new Date(iso);
const offset = at.getTimezoneOffset();
return new Date(at.getTime() - offset * 60_000).toISOString().slice(0, 10);
}
export function ReportVisitOffer({
onUseVisitDate,
}: {
/** Sets the custom window's END date. Called only on an explicit press. */
onUseVisitDate: (endDay: string) => void;
}) {
const { t } = useTranslations();
const format = useFormatters();
const [dismissed, setDismissed] = useState(false);
const visits = useEncounters();
// Read once, at mount. A clock read during render is impure — and here it
// would also mean the offer could vanish mid-interaction as a visit crossed
// the horizon between two renders.
const [now] = useState(() => Date.now());
if (dismissed) return null;
const horizon = now + REPORT_VISIT_HORIZON_DAYS * 24 * 60 * 60 * 1000;
const next = (visits.data?.upcoming ?? []).find((visit) => {
if (visit.status !== "PLANNED") return false;
const at = new Date(visit.occurredAt).getTime();
return at > now && at <= horizon;
});
if (!next) return null;
const what =
next.practitioner?.name ?? encounterKindText(t, next.kind as EncounterKind);
return (
<div
data-slot="report-visit-offer"
className="border-border flex flex-wrap items-center gap-2 rounded-lg border p-3"
>
{/* Content, not meta: it is the offer, and it names its own source. */}
<p className="text-foreground flex min-w-0 flex-1 items-center gap-2 text-sm">
<CalendarClock className="size-4 shrink-0" aria-hidden />
<span>
{t("settings.healthRecord.visitOffer", {
what,
when: format.date(next.occurredAt),
})}
</span>
</p>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="report-visit-offer-apply"
onClick={() => onUseVisitDate(localDay(next.occurredAt))}
>
{t("settings.healthRecord.visitOfferApply")}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 shrink-0 sm:size-9"
aria-label={t("common.dismiss")}
onClick={() => setDismissed(true)}
>
<X className="size-4" aria-hidden />
</Button>
</div>
);
}
@@ -44,6 +44,7 @@ import { DateField } from "@/components/ui/date-field";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { NativeSelect } from "@/components/ui/native-select";
import { ReportVisitOffer } from "./report-visit-offer";
import { Switch } from "@/components/ui/switch";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { apiFetchRaw } from "@/lib/api/api-fetch";
@@ -253,6 +254,15 @@ export function HealthRecordExportPanel() {
</div>
</div>
{/* Never auto-applies: pressing it is what opens the custom window and
sets its end date. */}
<ReportVisitOffer
onUseVisitDate={(endDay) => {
setCustomRange(true);
setEndDate(endDay);
}}
/>
{customRange ? (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">