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

+2
View File
@@ -20,6 +20,7 @@
import Link from "next/link";
import {
Activity,
CalendarCheck,
Award,
CalendarClock,
Footprints,
@@ -55,6 +56,7 @@ const KIND_ICON: Record<PriorityItemKind, LucideIcon> = {
ecg_new_recording: HeartPulse,
tension_window: Activity,
same_time_baseline: Footprints,
upcoming_visit: CalendarCheck,
};
/**
@@ -4,22 +4,30 @@ import { renderToStaticMarkup } from "react-dom/server";
/**
* The floating bulk bar, pinned via single-pass static renders: a labelled
* toolbar carrying the selected count and the bulk verbs (set type, link
* condition, share, delete, clear). The link-condition menu only renders when
* the account actually has episodes to link — no dead affordance on an
* episode-free account.
* condition, file against a visit, share, delete, clear). Each link menu only
* renders when the account actually has something to link to — no dead
* affordance on an account with no episodes, and none on an account with no
* visits either.
*/
import { I18nProvider } from "@/lib/i18n/context";
import { DocumentBulkBar } from "../document-bulk-bar";
function render(episodes: { id: string; label: string }[]) {
function render(
episodes: { id: string; label: string }[],
encounters: { id: string; label: string }[] = [
{ id: "v1", label: "Routine visit · 01/08/2026" },
],
) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<DocumentBulkBar
selectedCount={3}
episodes={episodes}
encounters={encounters}
busy={false}
onSetKind={() => {}}
onLinkEpisode={() => {}}
onLinkEncounter={() => {}}
onShare={() => {}}
onDelete={() => {}}
onClear={() => {}}
@@ -36,6 +44,7 @@ describe("<DocumentBulkBar>", () => {
expect(html).toContain("3 selected");
expect(html).toContain("Change type");
expect(html).toContain("Link condition");
expect(html).toContain("File against visit");
expect(html).toContain('data-slot="document-bulk-share"');
expect(html).toContain("Share");
expect(html).toContain("Delete");
@@ -47,4 +56,13 @@ describe("<DocumentBulkBar>", () => {
expect(html).not.toContain("Link condition");
expect(html).toContain("Change type");
});
it("omits the file-against-visit verb when the account has no visits", () => {
const html = render([{ id: "ep1", label: "Knee" }], []);
expect(html).not.toContain("File against visit");
expect(html).not.toContain('data-slot="document-bulk-link-visit"');
// The positive control: the neighbouring verb is still there, so the
// assertion above is about the visit menu and not about an empty render.
expect(html).toContain("Link condition");
});
});
@@ -30,6 +30,7 @@ function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
factCount: 0,
pendingCount: 0,
conditionLinks: [{ episodeId: "ep-knee", name: "Knie" }],
encounterLinks: [],
servingClass: "inline",
hasContentIndex: false,
contentIndexSource: null,
@@ -50,6 +50,7 @@ function doc(id: string, title: string): InboundDocumentDto {
factCount: 0,
pendingCount: 0,
conditionLinks: [{ episodeId: "ep1", name: "Knee" }],
encounterLinks: [],
servingClass: "inline",
hasContentIndex: false,
contentIndexSource: null,
@@ -38,6 +38,7 @@ function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
factCount: 0,
pendingCount: 0,
conditionLinks: [],
encounterLinks: [],
servingClass: "inline",
hasContentIndex: false,
contentIndexSource: null,
+55 -3
View File
@@ -4,8 +4,13 @@
* Floating bulk-action bar for the vault's multi-select mode. Appears once
* at least one document is selected, pinned above the bottom nav on phones
* and near the bottom edge on desktop. Carries the selected count and the
* bulk verbs — set type, link condition, share (one link for the whole
* selection), delete (undo-able), clear — all driving the page's handlers.
* bulk verbs — set type, link condition, file against a visit, share (one
* link for the whole selection), delete (undo-able), clear — all driving the
* page's handlers.
*
* The two link verbs are the same shape and stay that way: each renders only
* when the account HAS something to link to, so an empty menu is never
* offered.
*
* A `role="toolbar"` with proper labels; the destructive verb sits last
* before Clear so a stray tap sequence never ends on Delete. Share sits just
@@ -13,7 +18,14 @@
* shell (a floating toolbar is not a Card surface): dense-tile padding `p-3`
* per the standards.
*/
import { FolderPlus, Share2, Tag, Trash2, X } from "lucide-react";
import {
CalendarClock,
FolderPlus,
Share2,
Tag,
Trash2,
X,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -32,12 +44,20 @@ export interface BulkEpisodeOption {
label: string;
}
/** One visit the selection can be filed against. Label resolved by the page. */
export interface BulkEncounterOption {
id: string;
label: string;
}
export function DocumentBulkBar({
selectedCount,
episodes,
encounters,
busy,
onSetKind,
onLinkEpisode,
onLinkEncounter,
onShare,
onDelete,
onClear,
@@ -45,10 +65,13 @@ export function DocumentBulkBar({
selectedCount: number;
/** The caller's live illness episodes — the link-condition targets. */
episodes: BulkEpisodeOption[];
/** The caller's recent visits — the file-against-a-visit targets. */
encounters: BulkEncounterOption[];
/** A bulk call is in flight — the verbs disable, Clear stays live. */
busy: boolean;
onSetKind: (kind: InboundDocumentKindValue) => void;
onLinkEpisode: (episodeId: string) => void;
onLinkEncounter: (encounterId: string) => void;
/**
* Share the whole selection as ONE documents-only link. The page caps the
* selection at `SHARE_LINK_MAX_DOCUMENTS` and surfaces the over-cap hint —
@@ -135,6 +158,35 @@ export function DocumentBulkBar({
</DropdownMenu>
) : null}
{encounters.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={busy}
data-slot="document-bulk-link-visit"
aria-label={t("documents.bulk.linkVisit")}
>
<CalendarClock className="size-4" aria-hidden />
<span className="hidden sm:inline">
{t("documents.bulk.linkVisit")}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{encounters.map((encounter) => (
<DropdownMenuItem
key={encounter.id}
onSelect={() => onLinkEncounter(encounter.id)}
>
<span className="max-w-56 truncate">{encounter.label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button
variant="outline"
size="sm"
@@ -61,7 +61,9 @@ import {
apiPatch,
apiPost,
} from "@/lib/api/api-fetch";
import { encounterKindText } from "@/components/encounters/encounter-labels";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { EncounterKind } from "@/generated/prisma/client";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
import {
@@ -77,6 +79,7 @@ import {
type InboundDocumentKindValue,
} from "@/lib/validations/inbound-documents";
import { DocumentAiSection } from "./document-ai-section";
import { DocumentEncounterSuggestion } from "./document-encounter-suggestion";
import { DocumentSummaryBlock } from "./document-summary-block";
import type { DocumentAiTarget } from "./document-ai-transport";
import { DocumentShareSheet } from "./document-share-sheet";
@@ -96,6 +99,7 @@ type PatchInput = {
kind?: InboundDocumentKindValue;
documentDate?: string | null;
episodeIds?: string[];
encounterIds?: string[];
};
/**
@@ -396,6 +400,17 @@ export function DocumentDetailSheet({
const [mutationError, setMutationError] = useState<string | null>(null);
const [shareOpen, setShareOpen] = useState(false);
const [editingTitle, setEditingTitle] = useState(false);
/**
* The visit this SESSION filed the document against, if any.
*
* Kept beside the server state rather than derived from it, because the two
* answer different questions: `doc.encounterLinks` says what is filed, this
* says what the person just did here — which is what an undo can take back
* without touching a filing they made somewhere else.
*/
const [pickedEncounterId, setPickedEncounterId] = useState<string | null>(
null,
);
const [titleDraft, setTitleDraft] = useState("");
const [suggestion, setSuggestion] = useState<DocumentSuggestionDto | null>(
null,
@@ -499,6 +514,25 @@ export function DocumentDetailSheet({
patch.mutate({ episodeIds: next });
};
/**
* File this document against a visit, or clear that filing again.
*
* A replace-set PATCH like the condition links, so the undo is the same
* write with the id removed rather than a second endpoint. Nothing here can
* fail the document: an unknown id is refused by the route before anything
* is written, and the sheet keeps showing what the server last confirmed.
*/
const setSuggestedEncounter = (encounterId: string | null) => {
if (!doc) return;
const current = doc.encounterLinks.map((l) => l.encounterId);
const next =
encounterId === null
? current.filter((id) => id !== pickedEncounterId)
: [...new Set([...current, encounterId])];
setPickedEncounterId(encounterId);
patch.mutate({ encounterIds: next });
};
// The affordance gate falls back to the capability probe when the sheet is
// rendered without the usage-derived props (deep link before usage loads).
const aiEnabled = assistAvailable ?? capability.data?.available ?? false;
@@ -1001,6 +1035,47 @@ export function DocumentDetailSheet({
</div>
</div>
<DocumentEncounterSuggestion
anchor={doc.reportDate ?? doc.documentDate}
alreadyLinked={
doc.encounterLinks.length > 0 && pickedEncounterId === null
}
disabled={!canManage}
value={pickedEncounterId}
onChange={setSuggestedEncounter}
/>
{/* "Belongs to visit" — read-only here on purpose. The filing
happens at the moment the document arrives, or from the
visit's own sheet; a second editor for the same link would
be a second place the set can be changed. Each row deep-links
to the vault filtered to that visit. */}
{doc.encounterLinks.length > 0 ? (
<div className="space-y-1.5" data-slot="document-visit-links">
<p className="text-sm leading-none font-medium">
{t("documents.detail.visitsLabel")}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{doc.encounterLinks.map((link) => (
<Link
key={link.encounterId}
href={`/documents?encounter=${encodeURIComponent(link.encounterId)}`}
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-64 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
>
<span className="truncate">
{encounterKindText(t, link.kind as EncounterKind)}
</span>
{link.occurredAt ? (
<span className="text-muted-foreground shrink-0">
{format.date(link.occurredAt)}
</span>
) : null}
</Link>
))}
</div>
</div>
) : null}
{labsModuleEnabled && labFactCount > 0 ? (
<Link
href="/labs"
@@ -0,0 +1,43 @@
"use client";
/**
* The document moment of the visit suggestion.
*
* The rule and the rendering both live elsewhere — `suggest-window.ts` on the
* server and `EncounterSuggestionField` in the browser — so this file is only
* the wiring: which date to anchor on, and where the chosen id goes.
*
* The anchor is `reportDate ?? documentDate`. `reportDate` is what the model
* transcribed off the letter itself and is never inferred, so it is the truer
* of the two; `documentDate` is the person's own filing date and is the
* fallback.
*
* It renders nothing once the document already names a visit that this session
* did not choose: an offer to file something already filed is noise.
*/
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
export function DocumentEncounterSuggestion({
anchor,
alreadyLinked,
disabled,
value,
onChange,
}: {
anchor: string | null;
alreadyLinked: boolean;
disabled?: boolean;
value: string | null;
onChange: (encounterId: string | null) => void;
}) {
if (alreadyLinked) return null;
return (
<EncounterSuggestionField
anchor={anchor}
value={value}
onChange={onChange}
disabled={disabled}
slot="document-encounter-suggestion"
/>
);
}
+42 -2
View File
@@ -42,7 +42,10 @@ import { useAuth } from "@/hooks/use-auth";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { encounterKindText } from "@/components/encounters/encounter-labels";
import { useEncounters } from "@/hooks/use-encounters";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { EncounterKind } from "@/generated/prisma/client";
import {
type CoachCloseIntent,
useCoachLaunch,
@@ -85,6 +88,13 @@ interface ListPage {
}
// Strict end-of-input: `$` also accepts a trailing newline in JavaScript.
/**
* How many visits the bulk bar offers. A menu is a picker, not a history: past
* the first screenful a person scrolls rather than reads, and filing against a
* visit older than this belongs on the visit's own sheet.
*/
const BULK_VISIT_OPTIONS = 12;
const DOCUMENT_QUERY_ID_PATTERN = /^[a-zA-Z0-9_-]{1,40}(?![\s\S])/;
const DOCUMENT_SELECTION_HISTORY_KEY = "__healthlogPushedDocumentSelection";
@@ -226,6 +236,7 @@ export function closeDocumentSelectionAfterCoachHandoff(
export function DocumentsView() {
const { t } = useTranslations();
const format = useFormatters();
const { user, isLoading: authLoading, isAuthenticated } = useAuth();
// v1.36.x — uploading a document, filing it, sharing it and asking the AI
// about it are none of them delegated verbs. Inside somebody else's record
@@ -476,6 +487,27 @@ export function DocumentsView() {
.sort((a, b) => a.name.localeCompare(b.name));
}, [usage.data?.linkedEpisodes, filters.episodeId, episodes.data]);
// The visits the bulk bar can file a selection against: the account's own,
// read through the same list the checkups page shows. Labelled here rather
// than in the bar so the label uses the reader's locale for the kind and the
// reader's timezone for the date — both presentation, both this side's job.
const visits = useEncounters(
!inSharedRecord || sections === null || sections.includes("profile"),
);
const bulkEncounterOptions = useMemo(
() =>
[...(visits.data?.upcoming ?? []), ...(visits.data?.past ?? [])]
.slice(0, BULK_VISIT_OPTIONS)
.map((visit) => ({
id: visit.id,
label: `${
visit.practitioner?.name ??
encounterKindText(t, visit.kind as EncounterKind)
} · ${format.date(visit.occurredAt)}`,
})),
[visits.data, t, format],
);
// Year segmenter: years present in the loaded corpus (+ the active year).
const years = useMemo(() => {
const set = new Set<number>();
@@ -589,7 +621,11 @@ export function DocumentsView() {
const runBulk = useCallback(
(
action: Exclude<DocumentBulkAction, "delete" | "restore">,
extra: { kind?: InboundDocumentKindValue; episodeId?: string },
extra: {
kind?: InboundDocumentKindValue;
episodeId?: string;
encounterId?: string;
},
) => {
const ids = [...selectedIds];
bulk.mutate(
@@ -957,6 +993,10 @@ export function DocumentsView() {
busy={bulk.isPending}
onSetKind={(kind) => runBulk("setKind", { kind })}
onLinkEpisode={(episodeId) => runBulk("linkEpisode", { episodeId })}
encounters={bulkEncounterOptions}
onLinkEncounter={(encounterId) =>
runBulk("linkEncounter", { encounterId })
}
onShare={openBulkShare}
onDelete={() => deleteBulk([...selectedIds])}
onClear={clearSelection}
+11 -1
View File
@@ -18,7 +18,7 @@ import type { DocumentVaultFilters } from "@/lib/query-keys/documents";
const KIND_SET = new Set<string>(INBOUND_DOCUMENT_KINDS);
/**
* Parse the vault's URL search params (`?q&kind&episode&year`) into the
* Parse the vault's URL search params (`?q&kind&episode&encounter&year`) into the
* filter object. Unknown kinds are dropped (a hand-edited URL never 422s the
* page), `kind` accepts repeats AND comma-separated values, and the result is
* normalised (kinds sorted, empties omitted) so the same view always produces
@@ -42,6 +42,13 @@ export function parseVaultSearchParams(
const episode = params.get("episode")?.trim();
if (episode) filters.episodeId = episode.slice(0, 40);
// `?encounter=` is how a visit's own detail row reaches the vault filtered
// to what that visit produced. No chip in the filter bar backs it: the facet
// arrives by deep link from the visit and clears with the clear-all pill,
// which is exactly what a one-way link needs and no more.
const encounter = params.get("encounter")?.trim();
if (encounter) filters.encounterId = encounter.slice(0, 40);
const yearRaw = params.get("year");
if (yearRaw && /^\d{4}$/.test(yearRaw)) {
const year = Number(yearRaw);
@@ -64,6 +71,7 @@ export function vaultFiltersToSearch(filters: DocumentVaultFilters): string {
sp.set("kind", [...filters.kinds].sort().join(","));
}
if (filters.episodeId) sp.set("episode", filters.episodeId);
if (filters.encounterId) sp.set("encounter", filters.encounterId);
if (filters.year !== undefined) sp.set("year", String(filters.year));
return sp.toString();
}
@@ -74,6 +82,7 @@ export function countActiveFilters(filters: DocumentVaultFilters): number {
if (filters.q) count += 1;
if (filters.kinds && filters.kinds.length > 0) count += filters.kinds.length;
if (filters.episodeId) count += 1;
if (filters.encounterId) count += 1;
if (filters.year !== undefined) count += 1;
return count;
}
@@ -95,6 +104,7 @@ export function buildVaultListApiSearch(
sp.set("kind", filters.kinds.join(","));
}
if (filters.episodeId) sp.set("episodeId", filters.episodeId);
if (filters.encounterId) sp.set("encounterId", filters.encounterId);
if (filters.year !== undefined) sp.set("year", String(filters.year));
sp.set("sort", "documentDate");
sp.set("order", "desc");
@@ -0,0 +1,218 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
/**
* The one product constraint this whole feature exists to hold: **a visit
* saves with a date and nothing else.**
*
* Three properties, each of which has an obvious way to get broken by a later
* "improvement":
*
* - no field but the date can block Save;
* - the status is DERIVED from the date on a new visit, never asked;
* - a link picker whose module is off is BLANK, not empty — an empty picker
* for a switched-off module advertises a feature that is not there.
*/
let modules: Record<string, boolean> = {};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", modules },
isLoading: false,
isAuthenticated: true,
}),
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ canManage: true, canWrite: 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(() => {}),
apiPost: vi.fn(),
apiPatch: vi.fn(),
apiDelete: vi.fn(),
};
});
import { I18nProvider } from "@/lib/i18n/context";
import {
EncounterForm,
draftFromEncounter,
derivedStatus,
draftInstant,
draftToBody,
emptyDraft,
} from "../encounter-form";
/**
* The form body, rendered directly rather than through its sheet: the sheet is
* a Radix portal and renders nothing at all under `renderToStaticMarkup`, so a
* test that went through it would assert against an empty string and pass for
* the wrong reason.
*/
function renderForm(over?: {
modules?: Record<string, boolean>;
isEdit?: boolean;
}) {
modules = over?.modules ?? {};
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<EncounterForm
draft={emptyDraft({ occurredAt: "2026-08-08T10:00" })}
onChange={() => {}}
isEdit={over?.isEdit ?? false}
/>
</I18nProvider>
</QueryClientProvider>,
);
}
describe("nothing but the date is required", () => {
it("admits a draft carrying only a date", () => {
// `draftInstant(...) !== null` is the sheet's ONLY save gate, so a draft
// with no practitioner, no reason, no outcome and no link is saveable.
const draft = emptyDraft({ occurredAt: "2026-08-08T10:00" });
expect(draft.practitioner).toBeNull();
expect(draft.reason).toBe("");
expect(draft.documentIds).toEqual([]);
expect(draftInstant(draft.occurredAt)).not.toBeNull();
});
it("refuses only when the date itself cannot be read", () => {
expect(draftInstant("not a date")).toBeNull();
});
it("marks the date field required and nothing else", () => {
const html = renderForm();
const required = html.match(/aria-required="true"/g) ?? [];
expect(required).toHaveLength(1);
expect(html).toContain('data-testid="encounter-occurred-at"');
});
it("turns a date-only draft into a complete write body", () => {
const body = draftToBody(emptyDraft({ occurredAt: "2026-08-08T10:00" }), {
isEdit: false,
});
expect(body).not.toBeNull();
expect(body?.practitionerId).toBeNull();
expect(body?.reason).toBeNull();
expect(body?.outcome).toBeNull();
expect(body?.documentIds).toEqual([]);
});
it("refuses to build a body from a date that does not parse", () => {
expect(
draftToBody(emptyDraft({ occurredAt: "not a date" }), { isEdit: false }),
).toBeNull();
});
});
describe("editing never unfiles what the visit already collected", () => {
it("seeds the link arrays from the row's own links", () => {
// The defect this pins: an edit seeded from a row that did not know its
// links would save a replace-set of three EMPTY arrays and silently
// unfile everything. The list carries the links so this cannot happen.
const draft = draftFromEncounter({
id: "e1",
occurredAt: "2026-08-01T09:00:00.000Z",
status: "DONE",
kind: "ROUTINE",
practitioner: null,
reason: null,
outcome: null,
reminderNextDueAt: null,
links: {
documents: [{ id: "d1", label: "Letter", date: null, redacted: false }],
labResults: [{ id: "l1", label: "LDL", date: null, redacted: false }],
conditions: [{ id: "c1", label: "Knee", date: null, redacted: false }],
},
createdAt: "2026-08-01T09:00:00.000Z",
updatedAt: "2026-08-01T09:00:00.000Z",
});
expect(draft.documentIds).toEqual(["d1"]);
expect(draft.labResultIds).toEqual(["l1"]);
expect(draft.episodeIds).toEqual(["c1"]);
const body = draftToBody(draft, { isEdit: true });
expect(body?.documentIds).toEqual(["d1"]);
expect(body?.labResultIds).toEqual(["l1"]);
expect(body?.episodeIds).toEqual(["c1"]);
});
});
describe("the status follows the date on a new visit", () => {
const now = Date.parse("2026-08-08T12:00:00.000Z");
it("reads a future instant as a booked appointment", () => {
expect(derivedStatus("2026-08-09T09:00", now)).toBe("PLANNED");
});
it("reads a past instant as a visit that happened", () => {
expect(derivedStatus("2026-08-07T09:00", now)).toBe("DONE");
});
it("never asks for the status on a new visit", () => {
// The picker is what "asked" would look like; its absence is the property.
expect(renderForm()).not.toContain('id="encounter-status"');
});
it("carries the derived status into the body rather than the draft default", () => {
const body = draftToBody(
emptyDraft({ occurredAt: "2999-01-01T09:00", status: "DONE" }),
{ isEdit: false },
);
expect(body?.status).toBe("PLANNED");
});
it("uses the draft's own status on an edit, where it is editable", () => {
const body = draftToBody(
emptyDraft({ occurredAt: "2999-01-01T09:00", status: "CANCELLED" }),
{ isEdit: true },
);
expect(body?.status).toBe("CANCELLED");
});
});
describe("the link pickers follow the modules that own their targets", () => {
it("renders no link block at all when all three modules are off", () => {
const html = renderForm({ modules: {} });
expect(html).not.toContain('data-slot="encounter-link-pickers"');
expect(html).not.toContain('data-slot="encounter-link-documents"');
expect(html).not.toContain('data-slot="encounter-link-labs"');
expect(html).not.toContain('data-slot="encounter-link-conditions"');
});
it("blanks the block of a disabled module while keeping its neighbours", () => {
const html = renderForm({
modules: { inboundDocuments: true, labs: false, illness: false },
});
expect(html).toContain('data-slot="encounter-link-documents"');
// Not "renders an empty list" — absent.
expect(html).not.toContain('data-slot="encounter-link-labs"');
expect(html).not.toContain('data-slot="encounter-link-conditions"');
});
it("offers all three when all three modules are on", () => {
const html = renderForm({
modules: { inboundDocuments: true, labs: true, illness: true },
});
expect(html).toContain('data-slot="encounter-link-documents"');
expect(html).toContain('data-slot="encounter-link-labs"');
expect(html).toContain('data-slot="encounter-link-conditions"');
// The positive control: without it the three assertions above would pass
// against a component that never renders any block.
expect(html).toContain('data-slot="encounter-link-pickers"');
});
});
@@ -0,0 +1,155 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
/**
* The visits surface's designed states, pinned by single-pass static renders.
*
* - an account with no visits paints the empty state and one action, not an
* error and not a teaching wall;
* - upcoming sorts above past, in the two lists the SERVER split — the
* client renders the order it was given rather than re-deriving one;
* - a soft-deleted visit never arrives, so it cannot be rendered. That is a
* server property and the API test owns it; what this file pins is that
* the section renders exactly what the response carried, with nothing
* filtered or re-sorted on this side that could disagree with it.
*/
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", modules: {} },
isLoading: false,
isAuthenticated: true,
}),
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ canManage: true, canWrite: 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,
// Never resolves inside a synchronous render, so an unseeded query stays
// pending and cannot accidentally satisfy an assertion.
apiGet: () => new Promise(() => {}),
apiPost: vi.fn(),
apiPatch: vi.fn(),
apiDelete: vi.fn(),
};
});
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { EncounterDTO } from "@/lib/encounters/dto";
import { VisitsSection } from "../visits-section";
function visit(over: Partial<EncounterDTO> & { id: string }): EncounterDTO {
return {
occurredAt: "2026-08-01T09:00:00.000Z",
status: "DONE",
kind: "ROUTINE",
practitioner: null,
reason: null,
outcome: null,
reminderNextDueAt: null,
createdAt: "2026-08-01T09:00:00.000Z",
updatedAt: "2026-08-01T09:00:00.000Z",
...over,
};
}
function render(list: { upcoming: EncounterDTO[]; past: EncounterDTO[] }) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClient.setQueryData(
queryKeys.encounterList(null, null, undefined, undefined),
list,
);
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<VisitsSection />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<VisitsSection>", () => {
it("paints the empty state, with one action, when there is no visit", () => {
const html = render({ upcoming: [], past: [] });
expect(html).toContain("No visit recorded yet");
expect(html).toContain("A date is enough");
expect(html).toContain('data-slot="visits-add"');
// An empty account is not an error, and the two states never share copy.
expect(html).not.toContain("Visits could not be loaded.");
expect(html).not.toContain('data-slot="visits-upcoming"');
});
it("puts the upcoming list above the past one", () => {
const html = render({
upcoming: [visit({ id: "later", status: "PLANNED" })],
past: [visit({ id: "earlier" })],
});
const upcomingAt = html.indexOf('data-slot="visits-upcoming"');
const pastAt = html.indexOf('data-slot="visits-past"');
expect(upcomingAt).toBeGreaterThan(-1);
expect(pastAt).toBeGreaterThan(-1);
expect(upcomingAt).toBeLessThan(pastAt);
expect(html.indexOf('data-encounter-id="later"')).toBeLessThan(
html.indexOf('data-encounter-id="earlier"'),
);
});
it("renders the server's order inside a list rather than re-sorting it", () => {
// Two rows deliberately handed over in an order the client has no reason
// to like. Re-sorting here would be the second place the ordering lives.
const html = render({
upcoming: [],
past: [
visit({ id: "second", occurredAt: "2026-07-01T09:00:00.000Z" }),
visit({ id: "first", occurredAt: "2026-08-01T09:00:00.000Z" }),
],
});
expect(html.indexOf('data-encounter-id="second"')).toBeLessThan(
html.indexOf('data-encounter-id="first"'),
);
});
it("counts what a visit produced, from the links the LIST carried", () => {
// The counts come off `links`, and the list used to omit them — every card
// then read zero for a visit with three documents. The fixture carries a
// populated `links` because the response now does.
const html = render({
upcoming: [],
past: [
visit({
id: "linked",
links: {
documents: [
{ id: "d1", label: "Letter", date: null, redacted: false },
{ id: "d2", label: "Scan", date: null, redacted: false },
],
labResults: [
{ id: "l1", label: "LDL", date: null, redacted: false },
],
conditions: [],
},
}),
],
});
expect(html).toContain("2 linked documents");
expect(html).toContain("1 linked lab results");
// The empty family renders no counter rather than a zero.
expect(html).not.toContain("0 linked conditions");
});
it("offers the address book beside the add action", () => {
const html = render({ upcoming: [], past: [] });
expect(html).toContain('href="/checkups/practitioners"');
});
});
@@ -0,0 +1,283 @@
"use client";
/**
* The visit form.
*
* **A visit saves with a date and nothing else.** No required practitioner, no
* required kind, no required link, and no Save button disabled until something
* is picked. That is the whole product constraint this feature exists to hold:
* a required field on a form somebody fills after a doctor's appointment is the
* bureaucracy it was built to avoid. The only thing that can block Save is a
* missing or unparseable date, because a visit with no instant is not a record
* of anything.
*
* Status is DERIVED from the date on a new visit, never asked: a future instant
* means PLANNED and a past one means DONE. A person booking an appointment
* should not have to also tell the app that it is in the future. On an existing
* visit the status becomes editable, because moving a planned visit to done,
* cancelled or a no-show is the transition that closes a checkup.
*
* The three link pickers are gated by the modules that own what they point at,
* and the gate BLANKS the block rather than post-filtering it — an empty
* picker for a module the account turned off advertises a feature that is not
* there.
*/
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { DateTimeField } from "@/components/ui/date-time-field";
import { FieldGroup } from "@/components/ui/field-group";
import { NativeSelect } from "@/components/ui/native-select";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import type { Encounter, EncounterWriteBody } from "@/hooks/use-encounters";
import type { Practitioner } from "@/hooks/use-practitioners";
import {
ENCOUNTER_KINDS,
ENCOUNTER_STATUSES,
encounterKindText,
encounterStatusText,
} from "./encounter-labels";
import { PractitionerCombobox } from "./practitioner-combobox";
import { EncounterLinkPickers } from "./encounter-link-pickers";
export interface EncounterDraft {
/** Local `yyyy-MM-ddTHH:mm`, the `DateTimeField` contract. */
occurredAt: string;
status: "PLANNED" | "DONE" | "CANCELLED" | "NO_SHOW";
kind: string;
practitioner: Practitioner | null;
reason: string;
outcome: string;
documentIds: string[];
labResultIds: string[];
episodeIds: string[];
}
function localValue(instant: Date): string {
const offset = instant.getTimezoneOffset();
return new Date(instant.getTime() - offset * 60_000)
.toISOString()
.slice(0, 16);
}
export function nowLocalValue(): string {
return localValue(new Date());
}
/** A local `yyyy-MM-ddTHH:mm` back to the wire's ISO-8601 instant, or null. */
export function draftInstant(value: string): string | null {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
}
export function emptyDraft(
overrides?: Partial<EncounterDraft>,
): EncounterDraft {
return {
occurredAt: nowLocalValue(),
status: "DONE",
kind: "OTHER",
practitioner: null,
reason: "",
outcome: "",
documentIds: [],
labResultIds: [],
episodeIds: [],
...overrides,
};
}
export function draftFromEncounter(row: Encounter): EncounterDraft {
return {
occurredAt: localValue(new Date(row.occurredAt)),
status: row.status,
kind: row.kind,
practitioner: row.practitioner,
reason: row.reason ?? "",
outcome: row.outcome ?? "",
documentIds: row.links?.documents.map((link) => link.id) ?? [],
labResultIds: row.links?.labResults.map((link) => link.id) ?? [],
episodeIds: row.links?.conditions.map((link) => link.id) ?? [],
};
}
/**
* The status a NEW visit carries, read off its own date.
*
* Exported so the sheet and its tests agree on the derivation rather than each
* spelling out the comparison.
*/
export function derivedStatus(
occurredAt: string,
now = Date.now(),
): "PLANNED" | "DONE" {
const parsed = new Date(occurredAt).getTime();
return Number.isFinite(parsed) && parsed > now ? "PLANNED" : "DONE";
}
/** The body a draft becomes, with the empty strings turned into absences. */
export function draftToBody(
draft: EncounterDraft,
options: { isEdit: boolean; reminderId?: string | null },
): EncounterWriteBody | null {
const occurredAt = draftInstant(draft.occurredAt);
if (!occurredAt) return null;
return {
occurredAt,
status: options.isEdit ? draft.status : derivedStatus(draft.occurredAt),
kind: draft.kind,
practitionerId: draft.practitioner?.id ?? null,
reason: draft.reason.trim() || null,
outcome: draft.outcome.trim() || null,
...(options.reminderId ? { reminderId: options.reminderId } : {}),
documentIds: draft.documentIds,
labResultIds: draft.labResultIds,
episodeIds: draft.episodeIds,
};
}
export function EncounterForm({
draft,
onChange,
isEdit,
}: {
draft: EncounterDraft;
onChange: (next: EncounterDraft) => void;
isEdit: boolean;
}) {
const { t } = useTranslations();
const { user } = useAuth();
const patch = (part: Partial<EncounterDraft>) =>
onChange({ ...draft, ...part });
const planned = useMemo(
() =>
isEdit
? draft.status === "PLANNED"
: derivedStatus(draft.occurredAt) === "PLANNED",
[isEdit, draft.status, draft.occurredAt],
);
// The outcome field starts collapsed on a planned visit: there is nothing to
// write about a thing that has not happened. Derived rather than
// synchronised — a visit that already carries an outcome is never collapsed
// over it, and expressing that as an effect would be a second source of
// truth for something the draft already knows.
const [revealed, setRevealed] = useState(false);
const outcomeOpen = revealed || !planned || draft.outcome.length > 0;
return (
<div className="space-y-4" data-slot="encounter-form">
<FieldGroup
htmlFor="encounter-occurred-at"
label={t("encounters.form.date")}
hint={isEdit ? undefined : t("encounters.form.dateDerivesStatusHint")}
>
<DateTimeField
id="encounter-occurred-at"
value={draft.occurredAt}
onChange={(value) => patch({ occurredAt: value })}
required
aria-required="true"
data-testid="encounter-occurred-at"
/>
</FieldGroup>
{isEdit ? (
<FieldGroup
htmlFor="encounter-status"
label={t("encounters.form.status")}
>
<NativeSelect
id="encounter-status"
value={draft.status}
onChange={(event) =>
patch({
status: event.target.value as EncounterDraft["status"],
})
}
>
{ENCOUNTER_STATUSES.map((status) => (
<option key={status} value={status}>
{encounterStatusText(t, status)}
</option>
))}
</NativeSelect>
</FieldGroup>
) : null}
<FieldGroup
htmlFor="encounter-practitioner"
label={t("encounters.form.practitioner")}
hint={t("encounters.form.practitionerHint")}
>
<PractitionerCombobox
value={draft.practitioner?.id ?? null}
selected={draft.practitioner}
onChange={(next) => patch({ practitioner: next })}
/>
</FieldGroup>
<FieldGroup htmlFor="encounter-kind" label={t("encounters.form.kind")}>
<NativeSelect
id="encounter-kind"
value={draft.kind}
onChange={(event) => patch({ kind: event.target.value })}
>
{ENCOUNTER_KINDS.map((kind) => (
<option key={kind} value={kind}>
{encounterKindText(t, kind)}
</option>
))}
</NativeSelect>
</FieldGroup>
<FieldGroup
htmlFor="encounter-reason"
label={t("encounters.form.reason")}
>
<Textarea
id="encounter-reason"
rows={2}
value={draft.reason}
onChange={(event) => patch({ reason: event.target.value })}
/>
</FieldGroup>
{outcomeOpen ? (
<FieldGroup
htmlFor="encounter-outcome"
label={t("encounters.form.outcome")}
>
<Textarea
id="encounter-outcome"
rows={3}
value={draft.outcome}
onChange={(event) => patch({ outcome: event.target.value })}
/>
</FieldGroup>
) : (
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
data-slot="encounter-outcome-reveal"
onClick={() => setRevealed(true)}
>
{t("encounters.form.outcomeReveal")}
</Button>
)}
<EncounterLinkPickers
modules={user?.modules}
documentIds={draft.documentIds}
labResultIds={draft.labResultIds}
episodeIds={draft.episodeIds}
onChange={patch}
/>
</div>
);
}
@@ -0,0 +1,74 @@
"use client";
/**
* Browser-side labels for a visit's kind and status.
*
* The server has its own `encounterKindLabel` and the two do not share code on
* purpose: the server's answer becomes a push body in the RECORD OWNER's
* language, and this one renders in the language of whoever is looking. Sharing
* a resolver would force one of the two to read the wrong locale.
*
* Both switches spell out literal `t()` calls rather than interpolating a key.
* An interpolated key is invisible to `i18n-call-site-coverage`, so a renamed
* or missing label would ship as raw dot notation instead of failing CI.
*/
import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client";
type Translate = (key: string) => string;
/** Every kind, in the order the picker offers them. */
export const ENCOUNTER_KINDS: EncounterKind[] = [
"ROUTINE",
"ACUTE",
"SPECIALIST",
"PREVENTIVE",
"EMERGENCY",
"HOSPITAL",
"THERAPY",
"OTHER",
];
/** Every status a visit can be edited into. */
export const ENCOUNTER_STATUSES: EncounterStatus[] = [
"PLANNED",
"DONE",
"CANCELLED",
"NO_SHOW",
];
export function encounterKindText(t: Translate, kind: EncounterKind): string {
switch (kind) {
case "ROUTINE":
return t("encounters.kind.routine");
case "ACUTE":
return t("encounters.kind.acute");
case "SPECIALIST":
return t("encounters.kind.specialist");
case "PREVENTIVE":
return t("encounters.kind.preventive");
case "EMERGENCY":
return t("encounters.kind.emergency");
case "HOSPITAL":
return t("encounters.kind.hospital");
case "THERAPY":
return t("encounters.kind.therapy");
default:
return t("encounters.kind.other");
}
}
export function encounterStatusText(
t: Translate,
status: EncounterStatus,
): string {
switch (status) {
case "PLANNED":
return t("encounters.status.planned");
case "DONE":
return t("encounters.status.done");
case "CANCELLED":
return t("encounters.status.cancelled");
default:
return t("encounters.status.noShow");
}
}
@@ -0,0 +1,248 @@
"use client";
/**
* The visit form's three optional link blocks: documents, lab results,
* conditions.
*
* **The gate blanks the block, it does not post-filter it.** A module the
* account turned off leaves nothing behind here — no heading, no empty list,
* no "0 selected". An empty picker for a switched-off module advertises a
* feature that is not there and invites a person to wonder what is wrong. This
* is the same shape the dashboard snapshot uses for a disabled module.
*
* The encounter itself carries NO module gate. A visit is core, like the
* checkups page it lives on; what it may point AT follows the modules that own
* those things, which is why the gate lives here and not on the route.
*
* Nothing in this block can block a save. Every list starts empty and stays
* valid empty.
*/
import { useQuery } from "@tanstack/react-query";
import { FlaskConical, FolderOpen, Stethoscope } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import type { ModuleKey } from "@/lib/modules/registry";
import type { IllnessEpisodeDTO } from "@/lib/illness/dto";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
/** How many candidates one block offers before the person must use the sheet. */
const OPTION_LIMIT = 25;
interface Option {
id: string;
label: string;
/** ISO-8601, or null for a target with no date of its own. */
date: string | null;
}
function LinkBlock({
icon: Icon,
title,
options,
selected,
onToggle,
pending,
slot,
}: {
icon: LucideIcon;
title: string;
options: Option[];
selected: string[];
onToggle: (id: string) => void;
pending: boolean;
slot: string;
}) {
const { t } = useTranslations();
const format = useFormatters();
return (
<div className="space-y-2" data-slot={slot}>
<div className="flex items-center gap-2">
<Icon className="text-foreground size-4 shrink-0" aria-hidden />
<span className="text-sm font-medium">{title}</span>
{selected.length > 0 ? (
<Badge variant="outline" className="ml-auto">
{selected.length}
</Badge>
) : null}
</div>
{pending ? (
<Skeleton className="h-9 w-full rounded-md" />
) : options.length === 0 ? (
<p className="text-muted-foreground text-xs">
{t("encounters.form.linkNothingToOffer")}
</p>
) : (
<ul className="max-h-40 space-y-1 overflow-y-auto overscroll-contain">
{options.map((option) => {
const on = selected.includes(option.id);
return (
<li key={option.id}>
<button
type="button"
aria-pressed={on}
data-slot={`${slot}-option`}
onClick={() => onToggle(option.id)}
className={cn(
"border-border hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-2 rounded-md border px-3 text-left focus-visible:ring-[3px] focus-visible:outline-none",
on && "border-primary/40 bg-primary/5",
)}
>
<span className="text-foreground min-w-0 flex-1 truncate text-sm">
{option.label}
</span>
{option.date ? (
<span className="text-muted-foreground shrink-0 text-xs">
{format.date(option.date)}
</span>
) : null}
</button>
</li>
);
})}
</ul>
)}
</div>
);
}
function toggle(list: string[], id: string): string[] {
return list.includes(id)
? list.filter((entry) => entry !== id)
: [...list, id];
}
interface LabListPage {
results: Array<{
id: string;
analyte: string;
panel: string | null;
takenAt: string;
}>;
}
interface DocumentListPage {
documents: InboundDocumentDto[];
}
export function EncounterLinkPickers({
modules,
documentIds,
labResultIds,
episodeIds,
onChange,
}: {
modules: Partial<Record<ModuleKey, boolean>> | undefined;
documentIds: string[];
labResultIds: string[];
episodeIds: string[];
onChange: (part: {
documentIds?: string[];
labResultIds?: string[];
episodeIds?: string[];
}) => void;
}) {
const { t } = useTranslations();
const documentsOn = modules?.inboundDocuments === true;
const labsOn = modules?.labs === true;
const illnessOn = modules?.illness === true;
const documents = useQuery({
queryKey: queryKeys.inboundDocumentPicker("encounter-form"),
enabled: documentsOn,
queryFn: () =>
apiGet<DocumentListPage>(
`/api/documents/inbound?sort=documentDate&order=desc&limit=${OPTION_LIMIT}`,
),
});
const labs = useQuery({
queryKey: queryKeys.labResultsList({
analyte: undefined,
panel: undefined,
from: undefined,
to: undefined,
page: 0,
sortDir: "desc",
}),
enabled: labsOn,
queryFn: () =>
apiGet<LabListPage>(
`/api/labs?limit=${OPTION_LIMIT}&offset=0&sortDir=desc`,
),
});
const episodes = useQuery({
queryKey: queryKeys.illnessEpisodes(true),
enabled: illnessOn,
queryFn: () =>
apiGet<IllnessEpisodeDTO[]>(
`/api/illness/episodes?includeResolved=true&limit=${OPTION_LIMIT}`,
),
});
if (!documentsOn && !labsOn && !illnessOn) return null;
return (
<div className="space-y-4 border-t pt-4" data-slot="encounter-link-pickers">
{documentsOn ? (
<LinkBlock
icon={FolderOpen}
title={t("encounters.form.linkDocuments")}
slot="encounter-link-documents"
pending={documents.isPending}
selected={documentIds}
onToggle={(id) => onChange({ documentIds: toggle(documentIds, id) })}
options={(documents.data?.documents ?? []).map((doc) => ({
id: doc.id,
label: doc.title ?? doc.filename ?? doc.id,
date: doc.documentDate ?? doc.reportDate ?? doc.createdAt,
}))}
/>
) : null}
{labsOn ? (
<LinkBlock
icon={FlaskConical}
title={t("encounters.form.linkLabResults")}
slot="encounter-link-labs"
pending={labs.isPending}
selected={labResultIds}
onToggle={(id) =>
onChange({ labResultIds: toggle(labResultIds, id) })
}
options={(labs.data?.results ?? []).map((result) => ({
id: result.id,
label: result.panel
? `${result.panel} · ${result.analyte}`
: result.analyte,
date: result.takenAt,
}))}
/>
) : null}
{illnessOn ? (
<LinkBlock
icon={Stethoscope}
title={t("encounters.form.linkConditions")}
slot="encounter-link-conditions"
pending={episodes.isPending}
selected={episodeIds}
onToggle={(id) => onChange({ episodeIds: toggle(episodeIds, id) })}
options={(episodes.data ?? []).map((episode) => ({
id: episode.id,
label: episode.label,
date: episode.onsetAt,
}))}
/>
) : null}
</div>
);
}
@@ -0,0 +1,207 @@
"use client";
/**
* The sheet the visit form lives in — create and edit, one surface.
*
* Save is disabled only when the date cannot be parsed. Nothing else on the
* form can block it, which is the property the whole feature is checked
* against.
*
* Two entry points pre-fill it and neither changes what is required:
*
* - `reminderId` — opened from a due checkup. Filing the visit as DONE closes
* that checkup through the server's forward-only, idempotent path, so a
* person who both ticks the box and files a visit gets one satisfaction and
* no error.
* - `episodeId` — opened from a condition's "Visits about this" card, with
* the episode already linked.
*
* A delegate who may file the visit but not close the checkup gets the visit
* saved and a plain sentence saying the checkup stayed open. That is reported
* rather than skipped in silence: somebody who believes a checkup was closed
* will not look at it again.
*/
import { useState } from "react";
import { Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { useTranslations } from "@/lib/i18n/context";
import { useEncounterMutations, type Encounter } from "@/hooks/use-encounters";
import {
EncounterForm,
draftFromEncounter,
draftInstant,
draftToBody,
emptyDraft,
nowLocalValue,
type EncounterDraft,
} from "./encounter-form";
export function EncounterSheet({
open,
onOpenChange,
encounter,
reminderId,
episodeId,
onSaved,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
/** `null` creates a visit; a row edits it. */
encounter: Encounter | null;
/** The due checkup this visit is being filed against, when there is one. */
reminderId?: string | null;
/** A condition to pre-link, when the sheet was opened from an episode. */
episodeId?: string | null;
onSaved?: (saved: Encounter) => void;
}) {
const { t } = useTranslations();
const { create, update, remove } = useEncounterMutations();
const [confirmingDelete, setConfirmingDelete] = useState(false);
// Seeded once, at mount. The caller remounts this component on every open by
// varying its `key`, so a second edit never shows the first one's values and
// a create after an edit starts empty — without an effect that copies props
// into state and briefly renders the stale pair.
const [draft, setDraft] = useState<EncounterDraft>(() =>
encounter
? draftFromEncounter(encounter)
: emptyDraft({
occurredAt: nowLocalValue(),
episodeIds: episodeId ? [episodeId] : [],
}),
);
const [error, setError] = useState<string | null>(null);
const isEdit = encounter !== null;
const pending = create.isPending || update.isPending;
// The one gate. A date that does not parse is not a visit; everything else
// on this form is optional by design.
const dateUsable = draftInstant(draft.occurredAt) !== null;
const submit = async () => {
const body = draftToBody(draft, { isEdit, reminderId });
if (!body) return;
try {
const saved = isEdit
? await update.mutateAsync({ id: encounter.id, body })
: await create.mutateAsync(body);
// The server names what its grant could not reach. Today that is only a
// checkup closure, and a delegate seeing nothing would believe it closed
// — so a write that did part of what was asked reports `partial`, not a
// green tick, through the one module that owns the affordance.
const wroteEverything = (saved.skipped?.links ?? 0) === 0;
toastWrittenOutcome(
wroteEverything ? "success" : "partial",
wroteEverything
? t(isEdit ? "encounters.updated" : "encounters.created")
: t("encounters.checkupNotClosed"),
);
onSaved?.(saved);
onOpenChange(false);
} catch (err) {
const message =
err instanceof Error ? err.message : t("encounters.saveFailed");
setError(message);
toast.error(message);
}
};
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t(isEdit ? "encounters.editTitle" : "encounters.createTitle")}
description={t("encounters.formDescription")}
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
type="button"
className="min-h-11"
data-slot="encounter-save"
disabled={!dateUsable || pending}
onClick={() => void submit()}
>
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4">
<EncounterForm draft={draft} onChange={setDraft} isEdit={isEdit} />
{error ? (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
) : null}
{isEdit ? (
<AlertDialog
open={confirmingDelete}
onOpenChange={setConfirmingDelete}
>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="destructive"
size="sm"
className="min-h-11"
data-slot="encounter-delete"
>
<Trash2 className="size-4" aria-hidden />
{t("common.delete")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("encounters.deleteTitle")}
</AlertDialogTitle>
{/* Says what goes with it. Deleting a booked visit retires its
reminder, and a person who booked one deserves to know the
nudge stops rather than discover it by silence. */}
<AlertDialogDescription>
{t("encounters.deleteDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
remove.mutate(encounter.id, {
onSuccess: () => onOpenChange(false),
onError: () => toast.error(t("encounters.deleteFailed")),
});
}}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
</div>
</ResponsiveSheet>
);
}
@@ -0,0 +1,130 @@
"use client";
/**
* "Gehört zu Termin …" — the one rendering of the suggestion, for all three
* moments that ask it.
*
* The RULE is not here. It lives in `src/lib/encounters/suggest-window.ts` and
* is resolved server-side, so a document arriving in the vault, a lab panel
* committed from an extraction and a panel typed by hand cannot answer the same
* question differently. What this component owns is only how the three verdicts
* look, and it owns that once:
*
* one candidate → pre-selected, visibly, with one undo;
* two or more → a picker with NOTHING pre-selected;
* none → nothing at all. No empty picker, no "create a visit?" nudge.
*
* The middle branch is the point. Pre-selecting the first of two is the failure
* that teaches a person to stop reading suggestions, and the component cannot
* do it: the "many" verdict carries no pre-selection and this file never picks
* one.
*
* It never blocks a save — every caller treats the value as optional — and it
* is never behind an AI call: the match is a date-window query over the
* person's own rows and resolves on an account with no provider configured.
*
* Controlled. The caller owns where the chosen id goes: straight into a PATCH
* on a document, or into the body of a lab write that has not happened yet.
*/
import { CalendarClock, Check, Undo2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useEncounterSuggestion } from "@/hooks/use-encounters";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { EncounterKind } from "@/generated/prisma/client";
import type { EncounterSuggestion } from "@/lib/encounters/suggest-window";
import { encounterKindText } from "./encounter-labels";
export function EncounterSuggestionField({
anchor,
value,
onChange,
disabled,
slot = "encounter-suggestion",
}: {
/** The date to resolve around, ISO-8601. `null` means there is nothing to ask. */
anchor: string | null;
/** The visit currently chosen here, or null. */
value: string | null;
onChange: (encounterId: string | null) => void;
disabled?: boolean;
/** `data-slot` prefix, so a caller's tests can address its own instance. */
slot?: string;
}) {
const { t } = useTranslations();
const format = useFormatters();
const suggestion = useEncounterSuggestion(anchor, !disabled);
if (disabled) return null;
const result = suggestion.data;
if (!result || result.kind === "none") return null;
const label = (candidate: EncounterSuggestion) =>
`${
candidate.practitionerName ??
encounterKindText(t, candidate.kind as EncounterKind)
} · ${format.date(candidate.occurredAt)}`;
const candidates =
result.kind === "one" ? [result.encounter] : result.encounters;
return (
<div
className="space-y-1.5"
data-slot={slot}
data-verdict={result.kind}
// The count is on the element so a test can prove the rule reached the
// surface rather than inferring it from how many buttons rendered.
data-candidates={candidates.length}
>
{/* A content sentence, not meta (§3): it is the offer itself. */}
<p className="text-foreground flex items-center gap-2 text-sm">
<CalendarClock className="size-4 shrink-0" aria-hidden />
{t(
result.kind === "one"
? "encounters.suggestion.single"
: "encounters.suggestion.choose",
)}
</p>
<div className="flex flex-wrap items-center gap-1.5">
{candidates.map((candidate) => {
const chosen = value === candidate.id;
return (
<Button
key={candidate.id}
type="button"
variant={chosen ? "default" : "outline"}
size="sm"
className="min-h-11"
aria-pressed={chosen}
data-slot={`${slot}-option`}
onClick={() => onChange(chosen ? null : candidate.id)}
>
{result.kind === "one" ? (
<Check
className={cn("size-4", chosen ? "opacity-100" : "opacity-0")}
aria-hidden
/>
) : null}
{label(candidate)}
</Button>
);
})}
{value !== null ? (
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11"
data-slot={`${slot}-undo`}
onClick={() => onChange(null)}
>
<Undo2 className="size-4" aria-hidden />
{t("common.undo")}
</Button>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,177 @@
"use client";
/**
* The practice picker on the visit form.
*
* A combobox rather than a select, because the address book grows past the
* length a dropdown reads well at and a person types the first three letters
* of a practice name faster than they scroll to it. The search runs in SQL over
* the two plaintext columns — that is why they are plaintext.
*
* "Add new" opens a nested sheet rather than navigating away. A person filling
* a half-finished visit form who is sent to another page to add a doctor comes
* back to an empty form, so the affordance that looks helpful is the one that
* loses their work.
*
* The field is OPTIONAL and says so: a person can log "the emergency room on
* the 3rd" before they know whose name was on the door.
*/
import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { usePractitioners, type Practitioner } from "@/hooks/use-practitioners";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { PractitionerSheet } from "@/components/practitioners/practitioner-sheet";
export function PractitionerCombobox({
value,
onChange,
/** The already-resolved entry, so the closed trigger names it without a read. */
selected,
disabled,
}: {
value: string | null;
onChange: (next: Practitioner | null) => void;
selected: Practitioner | null;
disabled?: boolean;
}) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
const [term, setTerm] = useState("");
const [adding, setAdding] = useState(false);
// Bumped on each open so the nested sheet remounts empty rather than showing
// whatever the last attempt left in it.
const [session, setSession] = useState(0);
const debounced = useDebouncedValue(term, 250);
const list = usePractitioners(debounced, open);
const rows = list.data ?? [];
return (
<>
<div className="flex items-center gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
data-slot="encounter-practitioner-trigger"
className="min-h-11 min-w-0 flex-1 justify-between font-normal"
>
<span
className={cn(
"truncate",
selected ? "text-foreground" : "text-muted-foreground",
)}
>
{selected?.name ?? t("encounters.form.practitionerNone")}
</span>
<ChevronsUpDown
className="size-4 shrink-0 opacity-50"
aria-hidden
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-2">
<Input
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder={t("encounters.form.practitionerSearch")}
aria-label={t("encounters.form.practitionerSearch")}
data-slot="encounter-practitioner-search"
/>
<div className="mt-2 max-h-56 space-y-1 overflow-y-auto overscroll-contain">
{list.isPending ? (
<Skeleton className="h-9 w-full rounded-md" />
) : rows.length === 0 ? (
<p className="text-muted-foreground px-2 py-3 text-sm">
{t("encounters.form.practitionerNoMatch")}
</p>
) : (
rows.map((row) => (
<button
key={row.id}
type="button"
data-slot="encounter-practitioner-option"
onClick={() => {
onChange(row);
setOpen(false);
}}
className="hover:bg-muted/60 flex min-h-11 w-full items-center gap-2 rounded-md px-2 text-left text-sm"
>
<Check
className={cn(
"size-4 shrink-0",
row.id === value ? "opacity-100" : "opacity-0",
)}
aria-hidden
/>
<span className="min-w-0 flex-1">
<span className="text-foreground block truncate">
{row.name}
</span>
{row.practice ? (
<span className="text-muted-foreground block truncate text-xs">
{row.practice}
</span>
) : null}
</span>
</button>
))
)}
</div>
<Button
type="button"
variant="outline"
size="sm"
className="mt-2 min-h-11 w-full"
onClick={() => {
setOpen(false);
setAdding(true);
setSession((n) => n + 1);
}}
>
<Plus className="size-4" aria-hidden />
{t("encounters.form.practitionerAdd")}
</Button>
</PopoverContent>
</Popover>
{selected ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 shrink-0"
aria-label={t("encounters.form.practitionerClear")}
onClick={() => onChange(null)}
>
<X className="size-4" aria-hidden />
</Button>
) : null}
</div>
{/* Nested, so the half-filled visit form behind it stays mounted. */}
<PractitionerSheet
key={session}
open={adding}
onOpenChange={setAdding}
practitioner={null}
onSaved={(created) => onChange(created)}
/>
</>
);
}
+127
View File
@@ -0,0 +1,127 @@
"use client";
/**
* One visit, as a row in the list.
*
* The date, the practice and the reason are what the person wrote, so all three
* are `text-foreground`; the counts and the relative distance are meta and
* muted. Status is carried by the SECTION rather than repeated on every card —
* a card in the upcoming list is planned by construction, and a badge saying so
* on every row is noise that hides the two rows where the status is a surprise.
* A cancelled or missed visit is the exception and says so, because those are
* the ones a person is scanning for.
*/
import {
CalendarClock,
FileText,
FlaskConical,
Stethoscope,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { Encounter } from "@/hooks/use-encounters";
import type { EncounterKind, EncounterStatus } from "@/generated/prisma/client";
import { encounterKindText, encounterStatusText } from "./encounter-labels";
/** The two statuses worth calling out on a row; the rest the section implies. */
function isNoteworthy(status: EncounterStatus): boolean {
return status === "CANCELLED" || status === "NO_SHOW";
}
export function VisitCard({
encounter,
onOpen,
}: {
encounter: Encounter;
onOpen: (encounter: Encounter) => void;
}) {
const { t } = useTranslations();
const format = useFormatters();
const counts = [
{
icon: FileText,
n: encounter.links?.documents.length ?? 0,
label: t("encounters.card.documentCount"),
},
{
icon: FlaskConical,
n: encounter.links?.labResults.length ?? 0,
label: t("encounters.card.labCount"),
},
{
icon: Stethoscope,
n: encounter.links?.conditions.length ?? 0,
label: t("encounters.card.conditionCount"),
},
].filter((entry) => entry.n > 0);
// The practice, or the kind when there is none. A visit with neither is not
// nameless — the kind always resolves, because it defaults to one.
const heading =
encounter.practitioner?.name ??
encounterKindText(t, encounter.kind as EncounterKind);
return (
<Card
data-slot="visit-card"
data-encounter-id={encounter.id}
className="gap-2 py-3 md:py-4"
>
<CardContent className="space-y-2">
<button
type="button"
onClick={() => onOpen(encounter)}
className="focus-visible:ring-ring/50 w-full space-y-2 text-left focus-visible:ring-[3px] focus-visible:outline-none"
>
<div className="flex items-start gap-2">
<CalendarClock
className="text-foreground mt-0.5 size-5 shrink-0"
aria-hidden
/>
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-medium">
{heading}
</p>
<p className="text-muted-foreground text-xs">
{format.dateTime(encounter.occurredAt)}
</p>
</div>
<Badge variant="outline" className="shrink-0">
{encounterKindText(t, encounter.kind as EncounterKind)}
</Badge>
</div>
{encounter.reason ? (
<p className="text-foreground line-clamp-1 text-sm">
{encounter.reason}
</p>
) : null}
{isNoteworthy(encounter.status as EncounterStatus) ||
counts.length ? (
<div className="flex flex-wrap items-center gap-3">
{isNoteworthy(encounter.status as EncounterStatus) ? (
<span className="text-muted-foreground text-xs">
{encounterStatusText(t, encounter.status as EncounterStatus)}
</span>
) : null}
{counts.map(({ icon: Icon, n, label }) => (
<span
key={label}
className="text-muted-foreground flex items-center gap-1 text-xs tabular-nums"
aria-label={`${n} ${label}`}
>
<Icon className="size-3.5" aria-hidden />
{n}
</span>
))}
</div>
) : null}
</button>
</CardContent>
</Card>
);
}
@@ -0,0 +1,147 @@
"use client";
/**
* "Termine" — the visits half of the checkups page.
*
* Two lists rather than one, and the split is the server's: upcoming reads
* soonest-first, past reads newest-first, and the API resolves both so the
* ordering lives in one place. A client that split and re-sorted one array
* would be the second place it lives.
*
* No new nav entry and no new route. `/checkups` already owns exactly the right
* sentence — when do I have to do what, where — and is deliberately not module
* gated, which is also why a visit carries no module key of its own.
*/
import { CalendarClock, ContactRound, History, Plus } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { SectionHeading } from "@/components/ui/section-heading";
import { Skeleton } from "@/components/ui/skeleton";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useEncounters, type Encounter } from "@/hooks/use-encounters";
import { useTranslations } from "@/lib/i18n/context";
import { EncounterSheet } from "./encounter-sheet";
import { VisitCard } from "./visit-card";
export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
const { t } = useTranslations();
const { canManage } = useRecordCapabilities();
const list = useEncounters(enabled);
// `null` closed, `"new"` create, a row edits it.
const [editing, setEditing] = useState<Encounter | "new" | null>(null);
const [session, setSession] = useState(0);
const openSheet = (target: Encounter | "new") => {
setEditing(target);
setSession((n) => n + 1);
};
const upcoming = list.data?.upcoming ?? [];
const past = list.data?.past ?? [];
const empty = upcoming.length === 0 && past.length === 0;
const addButton = canManage ? (
<Button
type="button"
className="min-h-11"
data-slot="visits-add"
onClick={() => openSheet("new")}
>
<Plus className="size-4" aria-hidden />
{t("encounters.add")}
</Button>
) : null;
return (
<section aria-labelledby="visits-section-title" className="space-y-4">
<PageHeader
titleId="visits-section-title"
title={t("encounters.sectionTitle")}
description={t("encounters.sectionDescription")}
actions={
<>
<Button asChild variant="outline" className="min-h-11">
<Link href="/checkups/practitioners">
<ContactRound className="size-4" aria-hidden />
{t("practitioners.linkLabel")}
</Link>
</Button>
{addButton}
</>
}
/>
{list.isError ? (
<QueryErrorCard
title={t("encounters.loadError")}
onRetry={() => void list.refetch()}
/>
) : list.isPending ? (
<div className="space-y-2">
{Array.from({ length: 3 }, (_, index) => (
<Skeleton key={index} className="h-20 w-full rounded-xl" />
))}
</div>
) : empty ? (
<EmptyState
icon={<CalendarClock className="size-6" aria-hidden />}
title={t("encounters.emptyTitle")}
description={t("encounters.emptyDescription")}
action={addButton ?? undefined}
ctaSize="lg"
/>
) : (
<div className="space-y-6">
{upcoming.length > 0 ? (
<div className="space-y-2" data-slot="visits-upcoming">
<SectionHeading
icon={CalendarClock}
title={t("encounters.upcomingHeading")}
/>
{upcoming.map((encounter) => (
<VisitCard
key={encounter.id}
encounter={encounter}
onOpen={openSheet}
/>
))}
</div>
) : null}
{past.length > 0 ? (
<div className="space-y-2" data-slot="visits-past">
<SectionHeading
icon={History}
title={t("encounters.pastHeading")}
/>
{past.map((encounter) => (
<VisitCard
key={encounter.id}
encounter={encounter}
onOpen={openSheet}
/>
))}
</div>
) : null}
</div>
)}
{/* `key` changes on every open, so the sheet remounts with a fresh draft
instead of copying props into state from an effect. It stays mounted
while closing, which is what keeps the exit animation. */}
<EncounterSheet
key={session}
open={editing !== null}
onOpenChange={(open) => {
if (!open) setEditing(null);
}}
encounter={editing === "new" ? null : editing}
/>
</section>
);
}
@@ -0,0 +1,151 @@
"use client";
/**
* "Termine dazu" — the visits filed against one condition episode.
*
* **From the episode side only, and that is a decision rather than an
* omission.** An episode spans weeks and a visit is a point, so matching from
* the VISIT side would produce false positives at a rate that teaches a person
* to ignore every suggestion in the product. Here the person already has the
* condition in front of them and is the one who knows; the card offers an
* action instead of a guess.
*
* Modelled on the episode's documents card, which does exactly this for the
* vault. Renders nothing when the illness module is off — a gated surface never
* leaks, and an episode page is unreachable in that state anyway.
*/
import { CalendarClock, Plus } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { EncounterSheet } from "@/components/encounters/encounter-sheet";
import { encounterKindText } from "@/components/encounters/encounter-labels";
import { TileHeader } from "@/components/insights/tile-header";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { ListRow } from "@/components/ui/list-row";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useEncounters } from "@/hooks/use-encounters";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { EncounterKind } from "@/generated/prisma/client";
export function EpisodeVisitsCard({ episodeId }: { episodeId: string }) {
const { t } = useTranslations();
const format = useFormatters();
const { user } = useAuth();
const { canManage } = useRecordCapabilities();
const [sheetOpen, setSheetOpen] = useState(false);
const [session, setSession] = useState(0);
const moduleEnabled = user?.modules?.illness === true;
// The filter runs in SQL: the two arms of the response are separately
// bounded, so narrowing here would page wrongly on a long history.
const list = useEncounters(moduleEnabled, { episodeId });
if (!moduleEnabled) return null;
if (list.isError) {
return (
<QueryErrorCard
title={t("encounters.loadError")}
onRetry={() => void list.refetch()}
/>
);
}
const visits = [...(list.data?.upcoming ?? []), ...(list.data?.past ?? [])];
return (
<>
<Card data-slot="episode-visits-card">
<CardContent className="space-y-4">
<TileHeader
icon={CalendarClock}
title={t("encounters.episodeCard.title")}
right={
visits.length > 0 ? (
<span className="text-muted-foreground text-xs tabular-nums">
{visits.length}
</span>
) : undefined
}
/>
{list.isPending ? (
<div className="space-y-2">
{Array.from({ length: 2 }, (_, index) => (
<Skeleton key={index} className="h-12 w-full rounded-lg" />
))}
</div>
) : visits.length === 0 ? (
// A quiet one-line absence, not a teaching empty state: the person
// came here about the condition, not to be taught about visits.
<p className="text-muted-foreground text-sm">
{t("encounters.episodeCard.emptyLine")}
</p>
) : (
<ul className="space-y-1.5">
{visits.map((visit) => (
<li key={visit.id}>
<ListRow asChild>
<Link
href="/checkups"
data-encounter-id={visit.id}
className="hover:bg-muted/50 focus-visible:ring-ring/50 flex items-center gap-3 focus-visible:ring-[3px] focus-visible:outline-none"
>
<CalendarClock
className="text-foreground size-5 shrink-0"
aria-hidden
/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{visit.practitioner?.name ??
encounterKindText(t, visit.kind as EncounterKind)}
</span>
<span className="text-muted-foreground shrink-0 text-xs">
{format.date(visit.occurredAt)}
</span>
</Link>
</ListRow>
</li>
))}
</ul>
)}
{canManage ? (
<div className="flex flex-wrap items-center gap-1.5">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11"
data-slot="episode-visits-add"
onClick={() => {
setSheetOpen(true);
setSession((n) => n + 1);
}}
>
<Plus className="size-4" aria-hidden />
{t("encounters.episodeCard.add")}
</Button>
</div>
) : null}
</CardContent>
</Card>
{canManage ? (
<EncounterSheet
key={session}
open={sheetOpen}
onOpenChange={setSheetOpen}
encounter={null}
// Pre-linked, so the person never has to find this condition again
// in a picker they already navigated away from.
episodeId={episodeId}
/>
) : null}
</>
);
}
@@ -11,6 +11,7 @@
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useState } from "react";
import { EpisodeVisitsCard } from "@/components/illness/episode-visits-card";
import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -156,6 +157,12 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
nothing when the documents module is off for this account. */}
{episode ? <EpisodeDocumentsCard episodeId={episode.id} /> : null}
{/* Condition ⇄ visits linking, condition side ONLY. A visit never
suggests an episode: an episode spans weeks and a visit is a point,
so the reverse match would be wrong often enough to train the person
to ignore every suggestion in the product. */}
{episode ? <EpisodeVisitsCard episodeId={episode.id} /> : null}
{episode ? (
<>
<LogDaySheet
@@ -0,0 +1,170 @@
/**
* What reaches the screen, not what the strings say.
*
* The copy guard reads the bundles; this reads the render. Between them they
* cover the two halves of the same rule — that the two readings of a day are
* shown side by side and never merged, that the forecast never appears without
* its band and its count, and that the deviation is measured against the band
* rather than against the midpoint.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { PrognosisReading } from "@/lib/analytics/mood-prognosis/read";
import { MoodPrognosisView } from "../mood-prognosis-card";
function render(reading: PrognosisReading): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MoodPrognosisView reading={reading} />
</I18nProvider>,
);
}
function present(over: Partial<PrognosisReading> = {}): PrognosisReading {
return {
present: true,
date: "2026-08-07",
predicted: 5.4,
ciLow: 4.1,
ciHigh: 6.8,
n: 42,
modelVersion: "mood-ridge-1",
computedAt: "2026-08-08T02:35:00.000Z",
ageDays: 1,
current: true,
provisional: false,
stage: "regular",
entries: 64,
seasonalUnlocked: false,
selfAssessment: 6,
deviation: "within",
deviationAmount: 0,
contributions: [
{ feature: "dimension:a2", contribution: 0.9 },
{ feature: "linked:steps", contribution: -0.4 },
],
...over,
} as PrognosisReading;
}
describe("<MoodPrognosisView>", () => {
it("shows both readings, and shows the person's own one first", () => {
const html = render(present());
expect(html).toContain('data-slot="mood-prognosis-self"');
expect(html).toContain('data-slot="mood-prognosis-expected"');
// The self block precedes the expected block in the document, which is
// what "the self-assessment leads" means to a screen reader.
expect(html.indexOf('data-slot="mood-prognosis-self"')).toBeLessThan(
html.indexOf('data-slot="mood-prognosis-expected"'),
);
// Both values are on screen as themselves. A merged single figure is the
// failure this whole feature is shaped to avoid.
expect(html).toContain("6.0");
expect(html).toContain("5.4");
});
it("never shows the value without its band and its count", () => {
const html = render(present());
expect(html).toContain("4.1");
expect(html).toContain("6.8");
expect(html).toContain("42");
});
it("phrases the forecast as a counterfactual", () => {
const html = render(present());
expect(html).toContain("would have been expected");
expect(html).not.toContain("your mood is");
});
it("measures the deviation against the band, not the midpoint", () => {
// 6.0 is above the 5.4 midpoint and inside the 4.1-6.8 band, so it is not
// a finding. A midpoint comparison would have called it one.
expect(render(present())).toContain("inside that range");
const above = render(
present({ selfAssessment: 8, deviation: "above", deviationAmount: 1.2 }),
);
expect(above).toContain("1.2 points above");
});
it("labels a provisional forecast as provisional", () => {
expect(render(present({ provisional: true }))).toContain(
'data-slot="mood-prognosis-provisional"',
);
expect(render(present())).not.toContain(
'data-slot="mood-prognosis-provisional"',
);
});
it("says which day it is about when that day is not today", () => {
const stale = render(present({ current: false, ageDays: 4 }));
expect(stale).toContain('data-slot="mood-prognosis-age"');
// Rendered through the account's own date-order preference rather than as
// the raw day key — under this render's `en` locale and the default AUTO
// preference that is month-first. The assertion is on the formatted form
// precisely because a raw `2026-08-07` on the screen would pass a looser
// one.
expect(stale).toContain("08/07/2026");
expect(stale).not.toContain("2026-08-07");
});
it("names what the model weighted, in the reader's own words", () => {
const html = render(present());
// The dimension's own label, the one the slider carries.
expect(html).toContain("Stress");
expect(html).toContain("Steps");
expect(html).toContain("counted towards a higher expected value");
expect(html).toContain("counted towards a lower expected value");
});
it("skips a feature key this build no longer knows", () => {
const html = render(
present({
contributions: [
{ feature: "retired:whatever", contribution: 2 },
{ feature: "dimension:a3", contribution: 1 },
],
}),
);
expect(html).not.toContain("retired:whatever");
expect(html).toContain('data-slot="mood-prognosis-explanation"');
});
it("renders nothing at all below the first rung", () => {
expect(
render({
present: false,
reason: "no-output-yet",
entries: 4,
nextThreshold: 15,
}),
).toBe("");
});
it("explains the learning phase with the count and the next step", () => {
const html = render({
present: false,
reason: "learning-phase",
entries: 22,
nextThreshold: 30,
});
expect(html).toContain("22");
expect(html).toContain("30");
// Absence, not an error and not a zero.
expect(html).not.toContain('data-slot="mood-prognosis-expected"');
});
it("refuses honestly when there are days but no pattern", () => {
const html = render({
present: false,
reason: "no-pattern",
entries: 140,
nextThreshold: null,
});
expect(html).toContain("140");
expect(html).not.toContain('data-slot="mood-prognosis-statement"');
});
});
@@ -0,0 +1,248 @@
"use client";
/**
* The day's two readings, side by side, with the person's own one leading.
*
* The copy rules this component exists to hold, stated once so nobody has to
* infer them from the strings:
*
* * **No sentence names a cause.** Not "overtime lowered your mood", not
* "poor sleep is why today was hard". What the data supports is "on your
* own past days, a value around X would have been expected", and that is
* what it says. The ban is a test, not a habit:
* `src/__tests__/copy-matches-behaviour-guard.test.ts` reads the six
* bundles and fails on a causal construction in any of them.
* * **The forecast is a counterfactual and the sentence says so.** Never
* "your mood is 5.4" — always "a value around 5.4 would have been
* expected". The difference is the whole distinction between the two
* readings.
* * **The count is on the same surface as the number**, not in a tooltip. A
* forecast built from thirty days and one built from three hundred look
* identical otherwise, and a reader who cannot see which one they are
* looking at cannot weigh it.
* * **The deviation is measured against the band, never against the
* midpoint.** Inside the band is "as expected"; outside it is the finding.
*
* The explanation is generated from the contributions the job stored for that
* day, so it names what the model actually weighted rather than a fresh
* calculation against a model that may since have been re-fitted.
*/
import { useQuery } from "@tanstack/react-query";
import { Compass } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import { formatDate } from "@/lib/date-format";
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { PrognosisReading } from "@/lib/analytics/mood-prognosis/read";
import { prognosisFeatureLabel } from "./prognosis-feature-label";
/** How many weighted features the explanation names. */
const EXPLAINED_FEATURES = 3;
function formatValue(value: number, locale: string): string {
return new Intl.NumberFormat(locale, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}).format(value);
}
export function MoodPrognosisCard() {
const { isAuthenticated } = useAuth();
const { data, isLoading } = useQuery({
queryKey: queryKeys.moodPrognosis(),
queryFn: async () => apiGet<PrognosisReading>("/api/mood/prognosis"),
enabled: isAuthenticated,
staleTime: 60_000,
});
if (isLoading) {
return <Skeleton className="min-h-24 w-full rounded-xl" />;
}
if (!data) return null;
return <MoodPrognosisView reading={data} />;
}
/**
* The card itself, over a resolved reading.
*
* Split from the query so the copy rules above can be asserted against real
* renders rather than against the strings alone: "the two values are never
* merged" is a property of what reaches the screen, and a bundle check cannot
* see it.
*/
export function MoodPrognosisView({ reading }: { reading: PrognosisReading }) {
const { t, locale } = useTranslations();
const dateFormat = useDateFormatPreference();
const data = reading;
// The day key is a plain calendar date and is rendered as one, through the
// account's own date-order preference and pinned to UTC — the same treatment
// every other day key in the app gets, so this card cannot show a different
// day from the list beside it.
const day = data.present ? formatDate(data.date, dateFormat, locale) : "";
// Below the first rung there is nothing to say and the surface says nothing
// rather than showing an empty frame with an apology in it.
if (!data.present && data.reason === "no-output-yet") return null;
return (
<Card data-slot="mood-prognosis" className="gap-2 py-3 md:py-4">
<CardHeader>
<TileHeader icon={Compass} title={t("insights.mood.prognosis.title")} />
</CardHeader>
<CardContent className="space-y-3">
{!data.present ? (
<p
className="text-muted-foreground text-sm"
data-slot="mood-prognosis-absent"
>
{data.reason === "learning-phase"
? t("insights.mood.prognosis.learning", {
entries: String(data.entries),
threshold: String(data.nextThreshold ?? 0),
})
: t("insights.mood.prognosis.noPattern", {
entries: String(data.entries),
})}
</p>
) : (
<>
<div className="flex flex-wrap items-end gap-x-8 gap-y-3">
{/* The self-assessment leads: it is the larger number, it comes
first, and it is the only one of the two the person gave. */}
<div data-slot="mood-prognosis-self">
<p className="text-muted-foreground text-xs">
{t("insights.mood.prognosis.selfLabel")}
</p>
<p className="text-foreground text-3xl font-semibold tabular-nums">
{data.selfAssessment === null
? t("mood.dayContext.notRecorded")
: formatValue(data.selfAssessment, locale)}
</p>
</div>
<div data-slot="mood-prognosis-expected">
<p className="text-muted-foreground text-xs">
{t("insights.mood.prognosis.expectedLabel")}
</p>
<p className="text-foreground text-xl font-medium tabular-nums">
{formatValue(data.predicted, locale)}
</p>
{/* The band and the count ride with the value, in the block a
screen reader reaches in the same breath. */}
<p className="text-muted-foreground text-xs tabular-nums">
{data.ciLow !== null && data.ciHigh !== null
? t("insights.mood.prognosis.band", {
low: formatValue(data.ciLow, locale),
high: formatValue(data.ciHigh, locale),
})
: null}
</p>
</div>
</div>
<p
className="text-foreground text-sm"
data-slot="mood-prognosis-statement"
>
{t("insights.mood.prognosis.statement", {
value: formatValue(data.predicted, locale),
date: day,
n: String(data.n),
})}
</p>
{data.provisional ? (
<p
className="text-muted-foreground text-xs"
data-slot="mood-prognosis-provisional"
>
{t("insights.mood.prognosis.provisional")}
</p>
) : null}
{!data.current ? (
<p
className="text-muted-foreground text-xs"
data-slot="mood-prognosis-age"
>
{t("insights.mood.prognosis.notToday", { date: day })}
</p>
) : null}
{data.deviation !== null && data.selfAssessment !== null ? (
<p
className="text-foreground text-sm"
data-slot="mood-prognosis-deviation"
>
{data.deviation === "within"
? t("insights.mood.prognosis.deviationWithin")
: data.deviation === "above"
? t("insights.mood.prognosis.deviationAbove", {
amount: formatValue(data.deviationAmount ?? 0, locale),
})
: t("insights.mood.prognosis.deviationBelow", {
amount: formatValue(data.deviationAmount ?? 0, locale),
})}
</p>
) : null}
<PrognosisExplanation contributions={data.contributions} />
<p className="text-muted-foreground text-xs">
{t("insights.mood.prognosis.caveat")}
</p>
</>
)}
</CardContent>
</Card>
);
}
function PrognosisExplanation({
contributions,
}: {
contributions: Array<{ feature: string; contribution: number }>;
}) {
const { t } = useTranslations();
const named = contributions
.map((item) => ({
...item,
label: prognosisFeatureLabel(item.feature, t),
}))
.filter(
(item): item is typeof item & { label: string } =>
item.label !== null && item.contribution !== 0,
)
.slice(0, EXPLAINED_FEATURES);
if (named.length === 0) return null;
return (
<div className="space-y-1" data-slot="mood-prognosis-explanation">
<p className="text-muted-foreground text-xs">
{t("insights.mood.prognosis.weightedTitle")}
</p>
<ul className="space-y-0.5">
{named.map((item) => (
<li
key={item.feature}
className="text-foreground text-sm"
data-slot="mood-prognosis-weighted-item"
>
{item.contribution > 0
? t("insights.mood.prognosis.weightedUp", { label: item.label })
: t("insights.mood.prognosis.weightedDown", {
label: item.label,
})}
</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,71 @@
/**
* A stored feature key, in the reader's own language.
*
* The keys in `MoodPrediction.features` are stable identifiers, not copy. This
* turns one into a label, and it does so out of the strings the capture
* surfaces already use: a person reads "Workload" here because that is what
* the slider they moved was called, not because a second wording was invented
* for the explanation.
*
* Every key it cannot place returns `null`, and that path is real rather than
* defensive: a row written by an older `modelVersion` can name a feature this
* build has dropped, and skipping it is better than rendering an identifier at
* somebody.
*/
import { parseFeatureKey } from "@/lib/analytics/mood-prognosis/feature-keys";
import { contextValueLabelKey } from "@/lib/mood/context-vocabulary";
import { getMoodDimension } from "@/lib/mood/dimensions";
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
/**
* The five linked figures, resolved through literal keys.
*
* A switch rather than a computed key so the call-site coverage guard can see
* every string this file can ask for. The keys are the capture sheet's own
* (`mood.dayContext.linked*`), which is the point: the label a figure carries
* in the explanation is the label it carries where it was read.
*/
function linkedLabel(metric: string, t: Translate): string | null {
switch (metric) {
case "sleepAsleep":
return t("mood.dayContext.linkedSleep");
case "steps":
return t("mood.dayContext.linkedSteps");
case "activeEnergy":
return t("mood.dayContext.linkedActiveEnergy");
case "restingHeartRate":
return t("mood.dayContext.linkedRestingHeartRate");
case "heartRateVariability":
return t("mood.dayContext.linkedHrv");
default:
return null;
}
}
export function prognosisFeatureLabel(
key: string,
t: Translate,
): string | null {
const parsed = parseFeatureKey(key);
if (parsed === null) return null;
if (parsed.family === "dimension") {
const dimension = getMoodDimension(parsed.dimension);
return dimension ? t(dimension.labelKey) : null;
}
if (parsed.family === "linked") return linkedLabel(parsed.metric, t);
const field = t(`mood.context.field.${parsed.field}`);
if (parsed.value === null) return field;
// "Working day: longer than usual" — the field and the value the person
// picked, joined by a pattern rather than by a hard-coded colon, because the
// punctuation between them is a locale's business.
return t("insights.mood.prognosis.featureValue", {
field,
value: t(contextValueLabelKey(parsed.field, parsed.value)),
});
}
+27 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
import { useId, useRef, useState } from "react";
import { useId, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
@@ -25,6 +25,7 @@ import { localizedApiError } from "@/lib/api/localized-error";
import { parseReferenceRange } from "@/lib/labs/parse-reference-range";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -118,6 +119,21 @@ export function LabForm({
const [value, setValue] = useState("");
const [valueText, setValueText] = useState("");
const [takenAt, setTakenAt] = useState(defaultTakenAtValue);
/**
* The visit this reading came out of, when the suggestion offered one.
*
* Deliberately NOT cleared by the keep-open reset below: a real lab report
* shares one draw date and one visit across every analyte, so the next row
* off the same report keeps both rather than asking again.
*/
const [visitId, setVisitId] = useState<string | null>(null);
// The `DateTimeField` value is a local `yyyy-MM-ddTHH:mm`; the suggestion
// asks the server in ISO-8601, so it is converted once here rather than in
// the shared field, which must not know about this form's input format.
const takenAtAnchor = useMemo(() => {
const parsed = new Date(takenAt);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
}, [takenAt]);
const [note, setNote] = useState("");
// The window the paper report printed for THIS reading. Optional and folded
// away by default: most entries are typed against a catalog marker and never
@@ -224,6 +240,7 @@ export function LabForm({
: {}),
takenAt: new Date(takenAt).toISOString(),
...(note.trim() ? { note: note.trim() } : {}),
...(visitId ? { encounterId: visitId } : {}),
});
toast.success(
t("labs.form.savedToast"),
@@ -449,6 +466,15 @@ export function LabForm({
</div>
</div>
{/* Anchored on the draw date, not on now: backdating a reading moves
the offer with it. */}
<EncounterSuggestionField
anchor={takenAtAnchor}
value={visitId}
onChange={setVisitId}
slot="lab-form-encounter-suggestion"
/>
{resultType === "numeric" && selected && referenceText ? (
<p className="text-muted-foreground text-xs">
{t("labs.referenceLabel")} {referenceText} {selected.unit}
+32 -1
View File
@@ -18,6 +18,7 @@ import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { Button } from "@/components/ui/button";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { ApiError } from "@/lib/api/api-fetch";
import { EncounterSuggestionField } from "@/components/encounters/encounter-suggestion-field";
import { useTranslations } from "@/lib/i18n/context";
import { OcrRowEditor } from "./ocr-row-editor";
@@ -128,6 +129,12 @@ export function OcrReviewDialog({
// filed into the Documents vault and cross-linked to the committed labs. Text
// mode keeps the image on-device, so nothing is retained there.
const [pickedFile, setPickedFile] = useState<File | null>(null);
/**
* The visit this panel came out of, if the review step offered one and the
* person took the offer. Never blocks the save and never pre-fills anything
* else: the panel commits with it unset exactly as before.
*/
const [visitId, setVisitId] = useState<string | null>(null);
// Text mode OCR's the image in the browser then POSTs the text; vision mode
// uploads the image. Both resolve with the same proposed-rows DTO.
@@ -143,10 +150,24 @@ export function OcrReviewDialog({
[rows],
);
/**
* The draw date the whole panel hangs off — the earliest `takenAt` the scan
* read. A panel is one draw; asking the suggestion about each row separately
* would ask the same question a dozen times and could answer it a dozen ways.
*/
const panelAnchor = useMemo(() => {
const dates = rows
.map((row) => row.takenAt)
.filter((value): value is string => Boolean(value))
.sort();
return dates.length > 0 ? `${dates[0]}T12:00:00.000Z` : null;
}, [rows]);
function reset() {
setStage("pick");
setRows([]);
setPickedFile(null);
setVisitId(null);
extract.reset();
commit.reset();
}
@@ -183,7 +204,7 @@ export function OcrReviewDialog({
return;
}
commit.mutate(
{ rows: payload, file: pickedFile },
{ rows: payload, file: pickedFile, encounterId: visitId },
{
onSuccess: (result) => {
// The commit re-checks each confirmed row against what is already
@@ -307,6 +328,16 @@ export function OcrReviewDialog({
<p className="text-muted-foreground text-sm">
{t("labs.ocr.foundCount", { count: rows.length })}
</p>
{/* Anchored on the draw date the scan read, so the offer is about
the panel rather than about today. */}
<EncounterSuggestionField
anchor={panelAnchor}
value={visitId}
onChange={setVisitId}
slot="labs-ocr-encounter-suggestion"
/>
{rows.map((row) => (
<OcrRowEditor
key={row.key}
+8 -1
View File
@@ -143,6 +143,12 @@ export interface OcrCommitInput {
* image stays on-device.
*/
file?: File | null;
/**
* The visit this panel came out of, when the review step offered one and the
* person took the offer. Optional always — a commit with none named succeeds
* unchanged.
*/
encounterId?: string | null;
}
/**
@@ -166,13 +172,14 @@ async function fileScanToVault(file: File): Promise<string | undefined> {
export function useOcrCommit() {
const queryClient = useQueryClient();
return useMutation<OcrCommitResult, Error, OcrCommitInput>({
mutationFn: async ({ rows, file }: OcrCommitInput) => {
mutationFn: async ({ rows, file, encounterId }: OcrCommitInput) => {
const documentId = file
? await fileScanToVault(file).catch(() => undefined)
: undefined;
return apiPost<OcrCommitResult>("/api/labs/ocr/commit", {
rows,
...(documentId ? { documentId } : {}),
...(encounterId ? { encounterId } : {}),
});
},
onSuccess: (_result, { file }) => {
@@ -20,7 +20,14 @@ import { useRouter } from "next/navigation";
import { CalendarClock, CheckCircle2, Plus } from "lucide-react";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTranslations, useDisplayTimezone } from "@/lib/i18n/context";
import { encounterKindText } from "@/components/encounters/encounter-labels";
import { useEncounters } from "@/hooks/use-encounters";
import {
useFormatters,
useTranslations,
useDisplayTimezone,
} from "@/lib/i18n/context";
import type { EncounterKind } from "@/generated/prisma/client";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -38,6 +45,15 @@ import {
const SUMMARY_LIMIT = 3;
/**
* How far ahead an appointment has to be before this card stops mentioning it.
*
* A dashboard summary answers "what is coming", and two weeks is the horizon a
* person plans in. Further out and the line is noise that pushes the checkups
* it sits under off the card.
*/
const APPOINTMENT_HORIZON_DAYS = 14;
function resolveLabel(
reminder: MeasurementReminder,
t: (key: string) => string,
@@ -54,6 +70,7 @@ export function VorsorgeDashboardCard() {
// Issue #490 — the same profile zone the checkups page derives its due
// phrase in, so the two screens still agree when the device sits elsewhere.
const displayTz = useDisplayTimezone();
const format = useFormatters();
const router = useRouter();
// The same answer `/checkups` gives the same action. Satisfying a reminder
// is not one of the verbs a delegation admits at any level
@@ -72,6 +89,26 @@ export function VorsorgeDashboardCard() {
const showLoading = !reminders && !isError;
const { satisfy } = useMeasurementReminderMutations();
const [now] = useState(() => Date.now());
/**
* The next appointment, read from the VISIT list and never from the reminder
* table.
*
* An ENCOUNTER-origin reminder is excluded from every preventive-care read,
* which is correct — an appointment is not a checkup, and relaxing that
* filter here would put one in the due list. But a notification about
* something invisible in the app is worse than no notification, so the
* appointment appears where a person looks, sourced from the record it
* actually lives in. A diff in this file that touches `origin` is the wrong
* fix.
*/
const visits = useEncounters();
const nextAppointment = (visits.data?.upcoming ?? []).find(
(visit) =>
visit.status === "PLANNED" &&
new Date(visit.occurredAt).getTime() - now <=
APPOINTMENT_HORIZON_DAYS * 24 * 60 * 60 * 1000,
);
const [capturing, setCapturing] = useState<MeasurementReminder | null>(null);
const [captureFooterEl, setCaptureFooterEl] = useState<HTMLDivElement | null>(
null,
@@ -183,6 +220,27 @@ export function VorsorgeDashboardCard() {
})}
</ul>
)}
{/* Visually distinct from a due checkup: a quiet line under the block
rather than a fourth row with an action, because there is nothing
to do about an appointment except keep it. */}
{nextAppointment ? (
<Link
href="/checkups"
data-slot="vorsorge-dashboard-next-visit"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex items-center gap-2 rounded-md border-t pt-2 text-xs transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
<CalendarClock className="size-3.5 shrink-0" aria-hidden />
<span className="truncate">
{t("encounters.dashboard.nextVisit", {
what:
nextAppointment.practitioner?.name ??
encounterKindText(t, nextAppointment.kind as EncounterKind),
when: format.date(nextAppointment.occurredAt),
})}
</span>
</Link>
) : null}
</CardContent>
<ResponsiveSheet
@@ -35,6 +35,7 @@ import { useRouter } from "next/navigation";
import {
Activity,
CalendarClock,
CalendarPlus,
CheckCircle2,
Plus,
RefreshCw,
@@ -50,6 +51,7 @@ import { relativeCalendarDate } from "@/lib/i18n/relative-time";
import { relativeDueKey } from "@/lib/measurement-reminders/due-day";
import { cn } from "@/lib/utils";
import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs";
import { EncounterSheet } from "@/components/encounters/encounter-sheet";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
AlertDialog,
@@ -238,6 +240,19 @@ export function VorsorgeSection({
const [editing, setEditing] = useState<MeasurementReminder | "new" | null>(
null,
);
/**
* The due checkup a visit is being filed against, if any.
*
* The visit sheet does the rest: saving it as DONE satisfies this reminder
* through the server's forward-only, idempotent path, so filing a visit AND
* ticking the box is one satisfaction rather than an error.
*/
const [visitForReminder, setVisitForReminder] = useState<string | null>(null);
const [visitSession, setVisitSession] = useState(0);
const openVisitFor = (reminder: MeasurementReminder) => {
setVisitForReminder(reminder.id);
setVisitSession((n) => n + 1);
};
const [form, setForm] = useState<FormState>(EMPTY_FORM);
// The value-capture sheet: holds the reminder whose measurement we are
// entering. Non-null ⇒ the MeasurementForm sheet is open for that row.
@@ -738,6 +753,7 @@ export function VorsorgeSection({
now={now}
view={prefs.view}
onPrimaryAction={() => onPrimaryAction(reminder)}
onFileVisit={() => openVisitFor(reminder)}
onRemove={() => remove.mutate(reminder.id)}
onEdit={() => openEdit(reminder)}
busy={
@@ -749,6 +765,18 @@ export function VorsorgeSection({
)}
</ul>
)}
<EncounterSheet
key={visitSession}
open={visitForReminder !== null}
onOpenChange={(open) => {
if (!open) setVisitForReminder(null);
}}
encounter={null}
reminderId={visitForReminder}
// The reminder list repaints from the encounter write's own
// invalidation; nothing extra to do here.
onSaved={() => setVisitForReminder(null)}
/>
</section>
);
}
@@ -779,6 +807,7 @@ function VorsorgeCard({
now,
view,
onPrimaryAction,
onFileVisit,
onRemove,
onEdit,
busy,
@@ -787,6 +816,8 @@ function VorsorgeCard({
now: number;
view: "cards" | "list";
onPrimaryAction: () => void;
/** Open the visit sheet with this checkup pre-filled, closing it on save. */
onFileVisit: () => void;
onRemove: () => void;
onEdit: () => void;
busy: boolean;
@@ -912,6 +943,30 @@ function VorsorgeCard({
// "do it now" wash; a button that changes colour by schedule state reads as
// a different control). Due-ness is communicated by the discreet coloured
// next-due text above.
/**
* "Termin eintragen" — the second way to close a checkup.
*
* Beside "erledigt" rather than instead of it, because the two answer
* different questions: one says the thing happened, the other says WHERE it
* happened and files the record of it. Saving the visit as DONE satisfies
* this reminder through the same forward-only, idempotent primitive the
* checkbox uses, so a person who does both gets one satisfaction and no
* error.
*/
const fileVisitButton = canManage ? (
<Button
type="button"
variant="outline"
className="min-h-11 w-full"
data-slot="vorsorge-file-visit"
onClick={onFileVisit}
disabled={busy}
>
<CalendarPlus className="h-4 w-4" />
{t("measurementReminders.fileVisit")}
</Button>
) : null;
const primaryButton = canManage ? (
<Button
type="button"
@@ -1003,6 +1058,24 @@ function VorsorgeCard({
: t("measurementReminders.markDone")}
</Button>
) : null}
{/* Icon-only in the dense list, labelled in the cards branch —
the same action either way, never one branch offering what
the other withholds. */}
{canManage ? (
<Button
type="button"
variant="outline"
size="icon"
className="size-11 sm:size-9"
data-slot="vorsorge-file-visit"
aria-label={t("measurementReminders.fileVisit")}
title={t("measurementReminders.fileVisit")}
onClick={onFileVisit}
disabled={busy}
>
<CalendarPlus className="h-4 w-4" aria-hidden />
</Button>
) : null}
{headerActions}
</div>
</CardContent>
@@ -1129,7 +1202,10 @@ function VorsorgeCard({
{/* Bottom-pinned single primary action — green when due (MOD-06). */}
{primaryButton ? (
<div className="mt-auto pt-0">{primaryButton}</div>
<div className="mt-auto space-y-1.5 pt-0">
{primaryButton}
{fileVisitButton}
</div>
) : null}
</CardContent>
</Card>
@@ -0,0 +1,228 @@
"use client";
/**
* The account's own address book of doctors and practices.
*
* **Record content, not configuration**, so it does not live in Settings: a
* settings surface configures behaviour, and a list of one's own doctors
* configures nothing. It sits beside the visits that name it.
*
* Deleting is a soft delete and the visits that referenced the entry keep it.
* The confirmation says so in one sentence, because a person deleting a doctor
* has every reason to fear their history goes with it.
*/
import { ContactRound, Pencil, Plus, Search, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Input } from "@/components/ui/input";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import {
usePractitionerMutations,
usePractitioners,
type Practitioner,
} from "@/hooks/use-practitioners";
import { useTranslations } from "@/lib/i18n/context";
import { PractitionerSheet } from "./practitioner-sheet";
export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
const { t } = useTranslations();
const { canManage } = useRecordCapabilities();
const [term, setTerm] = useState("");
const debounced = useDebouncedValue(term, 250);
const list = usePractitioners(debounced, enabled);
const { remove } = usePractitionerMutations();
const [editing, setEditing] = useState<Practitioner | "new" | null>(null);
const [deleting, setDeleting] = useState<Practitioner | null>(null);
// Bumped on every open so the sheet remounts with a fresh draft rather than
// copying props into state from an effect.
const [session, setSession] = useState(0);
const openSheet = (target: Practitioner | "new") => {
setEditing(target);
setSession((n) => n + 1);
};
const rows = list.data ?? [];
const addButton = canManage ? (
<Button
type="button"
className="min-h-11"
data-slot="practitioner-add"
onClick={() => openSheet("new")}
>
<Plus className="size-4" aria-hidden />
{t("practitioners.add")}
</Button>
) : null;
return (
<section className="space-y-4">
<PageHeader
title={t("practitioners.title")}
description={t("practitioners.description")}
backLink={{ href: "/checkups", label: t("encounters.sectionTitle") }}
actions={addButton}
/>
<div className="relative">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
aria-hidden
/>
<Input
type="search"
value={term}
onChange={(event) => setTerm(event.target.value)}
placeholder={t("practitioners.searchPlaceholder")}
aria-label={t("practitioners.searchPlaceholder")}
className="pl-9"
data-slot="practitioner-search"
/>
</div>
{list.isError ? (
<QueryErrorCard
title={t("practitioners.loadError")}
onRetry={() => void list.refetch()}
/>
) : list.isPending ? (
<div className="space-y-2">
{Array.from({ length: 3 }, (_, index) => (
<Skeleton key={index} className="h-16 w-full rounded-xl" />
))}
</div>
) : rows.length === 0 ? (
<EmptyState
icon={<ContactRound className="size-6" aria-hidden />}
title={t(
debounced
? "practitioners.noMatchTitle"
: "practitioners.emptyTitle",
)}
description={t(
debounced
? "practitioners.noMatchDescription"
: "practitioners.emptyDescription",
)}
action={debounced ? undefined : (addButton ?? undefined)}
ctaSize="lg"
/>
) : (
<ul className="space-y-2" data-slot="practitioner-list">
{rows.map((row) => (
<li key={row.id}>
<Card className="gap-2 py-3 md:py-4">
<CardContent className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<p className="text-foreground truncate text-sm font-medium">
{row.name}
</p>
{row.practice || row.specialty ? (
<p className="text-foreground truncate text-sm">
{[row.practice, row.specialty]
.filter(Boolean)
.join(" · ")}
</p>
) : null}
{row.location ? (
<p className="text-muted-foreground truncate text-xs">
{row.location}
</p>
) : null}
</div>
{canManage ? (
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="size-11"
aria-label={t("common.edit")}
onClick={() => openSheet(row)}
>
<Pencil className="size-4" aria-hidden />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11"
aria-label={t("common.delete")}
onClick={() => setDeleting(row)}
>
<Trash2 className="size-4" aria-hidden />
</Button>
</div>
) : null}
</CardContent>
</Card>
</li>
))}
</ul>
)}
<PractitionerSheet
key={session}
open={editing !== null}
onOpenChange={(open) => {
if (!open) setEditing(null);
}}
practitioner={editing === "new" ? null : editing}
/>
<AlertDialog
open={deleting !== null}
onOpenChange={(open) => {
if (!open) setDeleting(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("practitioners.deleteTitle")}
</AlertDialogTitle>
{/* The one sentence that matters: the old visits stay. */}
<AlertDialogDescription>
{t("practitioners.deleteDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => {
if (!deleting) return;
const id = deleting.id;
setDeleting(null);
remove.mutate(id, {
onError: () => toast.error(t("practitioners.deleteFailed")),
});
}}
>
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
@@ -0,0 +1,205 @@
"use client";
/**
* Add or correct one address-book entry.
*
* Six plaintext-ish fields and one encrypted note. `name` is the only required
* one, for the same reason the visit only requires a date: a person typing a
* practice into a form after a doctor's appointment should not be stopped by a
* phone number they do not have to hand.
*
* Reachable two ways — from the address-book page and nested inside the visit
* form's picker — so it owns no navigation of its own and reports what it
* created back to whoever opened it.
*/
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { FieldGroup } from "@/components/ui/field-group";
import { Input } from "@/components/ui/input";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "@/lib/i18n/context";
import {
usePractitionerMutations,
type Practitioner,
type PractitionerWriteBody,
} from "@/hooks/use-practitioners";
interface Draft {
name: string;
specialty: string;
practice: string;
location: string;
phone: string;
note: string;
}
const EMPTY: Draft = {
name: "",
specialty: "",
practice: "",
location: "",
phone: "",
note: "",
};
function draftFrom(row: Practitioner | null): Draft {
if (!row) return EMPTY;
return {
name: row.name,
specialty: row.specialty ?? "",
practice: row.practice ?? "",
location: row.location ?? "",
phone: row.phone ?? "",
note: row.note ?? "",
};
}
/** An empty field clears the column rather than writing an empty string. */
function orNull(value: string): string | null {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function PractitionerSheet({
open,
onOpenChange,
practitioner,
onSaved,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
/** `null` creates; a row edits it. */
practitioner: Practitioner | null;
onSaved?: (row: Practitioner) => void;
}) {
const { t } = useTranslations();
const { create, update } = usePractitionerMutations();
// Seeded once, at mount. The caller varies this component's `key` on every
// open, so a second edit never shows the first one's values and a create
// after an edit starts empty — without an effect that copies props into
// state and briefly renders the stale pair.
const [draft, setDraft] = useState<Draft>(() => draftFrom(practitioner));
const [error, setError] = useState<string | null>(null);
const pending = create.isPending || update.isPending;
const nameGiven = draft.name.trim().length > 0;
const submit = async () => {
if (!nameGiven) return;
const body: PractitionerWriteBody = {
name: draft.name.trim(),
specialty: orNull(draft.specialty),
practice: orNull(draft.practice),
location: orNull(draft.location),
phone: orNull(draft.phone),
note: orNull(draft.note),
};
try {
const saved = practitioner
? await update.mutateAsync({ id: practitioner.id, body })
: await create.mutateAsync(body);
onSaved?.(saved);
onOpenChange(false);
} catch (err) {
const message =
err instanceof Error ? err.message : t("practitioners.saveFailed");
setError(message);
toast.error(message);
}
};
const field = (key: keyof Draft) => ({
value: draft[key],
onChange: (
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => setDraft((prev) => ({ ...prev, [key]: event.target.value })),
});
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t(
practitioner ? "practitioners.editTitle" : "practitioners.createTitle",
)}
description={t("practitioners.formDescription")}
footer={
<>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
type="button"
disabled={!nameGiven || pending}
onClick={() => void submit()}
data-slot="practitioner-save"
className="min-h-11"
>
{t("common.save")}
</Button>
</>
}
>
<div className="space-y-4" data-slot="practitioner-form">
<FieldGroup htmlFor="practitioner-name" label={t("practitioners.name")}>
<Input
id="practitioner-name"
required
aria-required="true"
autoComplete="off"
{...field("name")}
/>
</FieldGroup>
<FieldGroup
htmlFor="practitioner-specialty"
label={t("practitioners.specialty")}
>
<Input id="practitioner-specialty" {...field("specialty")} />
</FieldGroup>
<FieldGroup
htmlFor="practitioner-practice"
label={t("practitioners.practice")}
>
<Input id="practitioner-practice" {...field("practice")} />
</FieldGroup>
<FieldGroup
htmlFor="practitioner-location"
label={t("practitioners.location")}
>
<Input id="practitioner-location" {...field("location")} />
</FieldGroup>
<FieldGroup
htmlFor="practitioner-phone"
label={t("practitioners.phone")}
>
<Input id="practitioner-phone" type="tel" {...field("phone")} />
</FieldGroup>
<FieldGroup
htmlFor="practitioner-note"
label={t("practitioners.note")}
hint={t("practitioners.noteHint")}
>
<Textarea id="practitioner-note" rows={3} {...field("note")} />
</FieldGroup>
{error ? (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
) : null}
</div>
</ResponsiveSheet>
);
}
@@ -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">