mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
feat(doctor-report): a report can mention a visit, and know why it exists
The VISITS leaf ships in the release that made a visit possible, because a selectable checkbox that produces nothing is worse than no checkbox. Leaf, collector and PDF renderer land together, and the leaf carries no LEAF_MODULE entry — like ALLERGIES beside it — because a visit is core and the whole report is already gated by doctorReport. The absence is commented at the map so a later reader does not read it as an oversight. The rows print the date, the practice with its specialty, the kind, the reason, the outcome and the conditions the visit was filed against. The two encrypted columns decrypt at render, fail-soft, and never enter the stored selection. A condition the person has since deleted is not named: the link row survives a soft delete, and printing a label they withdrew would put it in front of a doctor. No FHIR resource type is added. That deferral is already written at the Encounter model, and the bundle builder now says so at the point a reader would look for the missing section: FHIR splits the future from the past across Appointment and Encounter, which is a serialisation question this release does not answer. Two properties are free and worth stating: a stored selection never grows (unknown ids drop) and a clinician share link's sections are frozen write-once, so no existing report and no already-shared link gains a section. The panel also offers a booked visit as the report's reason — one query, one prefilled window end, one dismissible offer that names its own source and never applies itself. A window control that moves on its own is what makes an export panel untrustworthy.
This commit is contained in:
1 parent
c47122ccfe
commit
4d2f706dad
24 files changed
+550
-27
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");
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user