Merge branch 'main' into feat/vaccinations-phase-1

The visit stage's second half landed while this was in flight and both
touched the same three places. Resolved as one set: the link facade
carries the reverse document-to-visit direction and the new dose pair
side by side, the sharing literals hold both sets of admissions with the
count re-added on top, and the language bundles take the shorter module
description the trunk settled on.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 17:07:58 +02:00
commit e1a4b7631c
200 files changed
+13599 -2858

No files matched your search

@@ -36,6 +36,7 @@ const OWNER = {
accountId: "acct-owner",
username: "owner",
displayName: "Margarethe",
fullName: null,
access: "read" as const,
level: "read" as const,
sections: null,
@@ -7,6 +7,7 @@ const OWNER = {
accountId: "acct-owner",
username: "owner",
displayName: "Margarethe",
fullName: null,
access: "write" as const,
level: "write" as const,
sections: null,
+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,95 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* `<EcgDetail>` — one recording, addressed by id.
*
* The non-diagnostic framing is the load-bearing part and it is unchanged by
* the move to a route: the result shown is the DEVICE's, attributed to the
* device, and a non-normal device result adds the "discuss with a clinician"
* note while a normal one does not.
*
* What did change: the component reads by id rather than being handed a row
* from the list, so a cold deep link works — the positive control below is
* that a render with only an id in hand still paints the trace.
*/
const useQueryMock = vi.fn();
vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: unknown) => useQueryMock(opts),
}));
const { EcgDetail } = await import("../ecg-detail");
type EcgDetailResponse = import("../ecg-detail").EcgDetailResponse;
const DETAIL: EcgDetailResponse = {
recordedAt: "2026-06-01T09:15:00.000Z",
durationSeconds: 30,
samplingFrequency: 300,
averageHeartRate: 72,
lead: null,
classification: "IRREGULAR",
source: "WITHINGS",
samples: [0, 10, -5, 40, -20, 5, 0],
decimated: true,
};
function renderDetail(detail: Partial<EcgDetailResponse> = {}) {
useQueryMock.mockReturnValue({
data: { ...DETAIL, ...detail },
isLoading: false,
isError: false,
});
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<EcgDetail recordingId="ecg_1" />
</I18nProvider>,
);
}
describe("<EcgDetail> — non-diagnostic framing", () => {
it("attributes the result to the recording device and draws the waveform", () => {
const html = renderDetail();
expect(html).toContain('data-slot="ecg-detail"');
expect(html).toContain('data-slot="ecg-result"');
// Load-bearing: the verdict is the device's, attributed to the device.
expect(html).toContain("as reported by the recording device");
expect(html).toContain("Atrial fibrillation detected");
expect(html).toContain('data-slot="ecg-trace"');
});
it("adds the 'discuss with a clinician' note on a non-normal result", () => {
const html = renderDetail();
expect(html).toContain('data-slot="ecg-clinician-note"');
expect(html).toContain("discuss this recording with a clinician");
});
it("adds the clinician note for an INCONCLUSIVE result", () => {
const html = renderDetail({ classification: "INCONCLUSIVE" });
expect(html).toContain('data-slot="ecg-clinician-note"');
});
it("does NOT add the clinician note for a normal (not-detected) result", () => {
const html = renderDetail({ classification: "NOT_DETECTED" });
expect(html).not.toContain('data-slot="ecg-clinician-note"');
// The device attribution is still there.
expect(html).toContain("as reported by the recording device");
});
it("says a failed read failed instead of painting an empty recording", () => {
useQueryMock.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
});
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<EcgDetail recordingId="ecg_1" />
</I18nProvider>,
);
expect(html).toContain('data-slot="ecg-detail-error"');
expect(html).not.toContain('data-slot="ecg-trace"');
});
});
@@ -4,20 +4,19 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.28.50 — `<EcgSection>` / `<EcgDetail>` unit tests.
* `<EcgSection>` — the list of recorded strips.
*
* The load-bearing behaviour under test:
* - data-availability gating: the section un-mounts entirely when the
* user has no recordings or while the payload is in flight;
* - the NON-DIAGNOSTIC framing (mirrors `RhythmEventsCard`): the device's
* result is shown attributed to the device, a permanent disclaimer
* states HealthLog does not interpret / diagnose, and any non-normal
* device result adds the "discuss with a clinician" note — while a
* normal result does NOT.
* - data-availability gating: the section un-mounts entirely when the user
* has no recordings or while the payload is in flight;
* - the reading cap: a device that records every morning accumulates
* hundreds of strips, and the list paints the most recent handful;
* - the device's verdict rides the timestamp as a tag, and a row that has
* a waveform opens the recording's own address.
*
* `useAuth` + TanStack Query are mocked and the assertions run through SSR
* (the suite's node environment has no DOM), exactly like the sibling
* `rhythm-events-card` test.
* `rhythm-events-card` test. `<EcgDetail>` has its own spec.
*/
vi.mock("@/hooks/use-auth", () => ({
@@ -29,20 +28,8 @@ vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: unknown) => useQueryMock(opts),
}));
const { EcgSection, EcgDetail } = await import("../ecg-section");
interface EcgRecordingListItem {
id: string;
recordedAt: string;
durationSeconds: number | null;
samplingFrequency: number;
sampleCount: number;
averageHeartRate: number | null;
lead: string | null;
classification: "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null;
source: string;
hasWaveform: boolean;
}
const { EcgSection, ECG_OVERVIEW_LIMIT } = await import("../ecg-section");
type EcgRecordingListItem = import("../ecg-section").EcgRecordingListItem;
const IRREGULAR_REC: EcgRecordingListItem = {
id: "ecg_1",
@@ -63,44 +50,21 @@ const NORMAL_REC: EcgRecordingListItem = {
classification: "NOT_DETECTED",
};
const DETAIL = {
recordedAt: "2026-06-01T09:15:00.000Z",
durationSeconds: 30,
samplingFrequency: 300,
averageHeartRate: 72,
lead: null,
classification: "IRREGULAR" as const,
source: "WITHINGS",
samples: [0, 10, -5, 40, -20, 5, 0],
decimated: true,
};
function renderSection(
data:
{ recordings: EcgRecordingListItem[]; hasRecordings: boolean } | undefined,
props: { limit?: number } = {},
) {
useQueryMock.mockReturnValue({ data, isLoading: false });
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<EcgSection />
<EcgSection {...props} />
</I18nProvider>,
);
}
function renderDetail(
recording: EcgRecordingListItem,
resultLabel: string | null,
) {
useQueryMock.mockReturnValue({ data: DETAIL, isLoading: false });
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<EcgDetail
recording={recording}
resultLabel={resultLabel}
onBack={() => {}}
/>
</I18nProvider>,
);
function countRows(html: string): number {
return (html.match(/data-slot="ecg-row"/g) ?? []).length;
}
describe("<EcgSection>", () => {
@@ -119,65 +83,95 @@ describe("<EcgSection>", () => {
expect(html).toBe("");
});
it("renders the recording list with a device-result label per row", () => {
it("renders one row per recording with the device result as a tag", () => {
const html = renderSection({
recordings: [IRREGULAR_REC, NORMAL_REC],
hasRecordings: true,
});
expect(html).toContain('data-slot="ecg-card"');
expect(html).toContain('data-slot="ecg-list"');
const rows = (html.match(/data-slot="ecg-row"/g) ?? []).length;
expect(rows).toBe(2);
expect(countRows(html)).toBe(2);
expect(html).toContain("Atrial fibrillation detected");
expect(html).toContain("No signs of atrial fibrillation");
// The verdict sits beside the timestamp inside the row's own header
// line, not on a line of its own beneath it.
expect(html).toMatch(
/<span[^>]*data-slot="ecg-row-result"[^>]*class="[^"]*rounded-full/,
);
});
it("shows the permanent non-diagnostic disclaimer on the list", () => {
const eightRecordings = () =>
Array.from({ length: 8 }, (_, index) => ({
...NORMAL_REC,
id: `ecg_${index}`,
recordedAt: new Date(Date.UTC(2026, 5, 20 - index, 9, 15)).toISOString(),
}));
it("caps the OVERVIEW teaser at the most recent handful", () => {
// The teaser passes `limit`. Eight strips in, five out — and the five are
// the ones the route handed back first, which it orders `recordedAt desc`.
const recordings = eightRecordings();
const html = renderSection(
{ recordings, hasRecordings: true },
{ limit: ECG_OVERVIEW_LIMIT },
);
expect(ECG_OVERVIEW_LIMIT).toBe(5);
expect(countRows(html)).toBe(ECG_OVERVIEW_LIMIT);
for (const kept of recordings.slice(0, ECG_OVERVIEW_LIMIT)) {
expect(html, `${kept.id} should be listed`).toContain(
`/insights/ecg/${kept.id}`,
);
}
for (const dropped of recordings.slice(ECG_OVERVIEW_LIMIT)) {
expect(html, `${dropped.id} is past the teaser cap`).not.toContain(
`/insights/ecg/${dropped.id}`,
);
}
});
it("renders every recording when NO limit is given (the full page)", () => {
// The dedicated `/insights/ecg` page passes no `limit`, so an account with
// many strips reaches all of them — the cap is a teaser affordance, never
// a wall on the full surface. Eight seeded, eight rendered, every one
// reachable by its own address.
const recordings = eightRecordings();
const html = renderSection({ recordings, hasRecordings: true });
expect(countRows(html)).toBe(8);
for (const rec of recordings) {
expect(html, `${rec.id} must be reachable on the full page`).toContain(
`/insights/ecg/${rec.id}`,
);
}
});
it("opens the recording's own address when it has a waveform", () => {
const html = renderSection({
recordings: [IRREGULAR_REC],
hasRecordings: true,
});
expect(html).toContain('data-slot="ecg-disclaimer"');
expect(html).toContain(
"HealthLog does not read or interpret ECG recordings",
);
expect(html).toContain("does not provide a diagnosis");
expect(html).toContain('href="/insights/ecg/ecg_1"');
});
});
describe("<EcgDetail> — non-diagnostic framing", () => {
it("attributes the result to the recording device and draws the waveform", () => {
const html = renderDetail(IRREGULAR_REC, "Atrial fibrillation detected");
expect(html).toContain('data-slot="ecg-detail"');
expect(html).toContain('data-slot="ecg-result"');
// Load-bearing: the verdict is the device's, attributed to the device.
expect(html).toContain("as reported by the recording device");
expect(html).toContain("Atrial fibrillation detected");
// The waveform strip is rendered.
expect(html).toContain('data-slot="ecg-trace"');
// And the permanent disclaimer is present here too.
expect(html).toContain('data-slot="ecg-disclaimer"');
it("leaves a waveform-less recording unlinked", () => {
const html = renderSection({
recordings: [{ ...IRREGULAR_REC, hasWaveform: false }],
hasRecordings: true,
});
expect(countRows(html)).toBe(1);
expect(html).not.toContain('href="/insights/ecg/ecg_1"');
});
it("adds the 'discuss with a clinician' note on a non-normal result", () => {
const html = renderDetail(IRREGULAR_REC, "Atrial fibrillation detected");
expect(html).toContain('data-slot="ecg-clinician-note"');
expect(html).toContain("discuss this recording with a clinician");
});
it("adds the clinician note for an INCONCLUSIVE result", () => {
const html = renderDetail(
{ ...IRREGULAR_REC, classification: "INCONCLUSIVE" },
"Inconclusive recording",
);
expect(html).toContain('data-slot="ecg-clinician-note"');
});
it("does NOT add the clinician note for a normal (not-detected) result", () => {
const html = renderDetail(NORMAL_REC, "No signs of atrial fibrillation");
expect(html).not.toContain('data-slot="ecg-clinician-note"');
// But the disclaimer + device attribution are still present.
expect(html).toContain('data-slot="ecg-disclaimer"');
expect(html).toContain("as reported by the recording device");
it("carries no intro paragraph and no disclaimer block", () => {
// Both were removed: the page heading already says what the surface is,
// and the device attribution on the detail page is where the "this is
// the device's result" statement belongs.
const html = renderSection({
recordings: [IRREGULAR_REC],
hasRecordings: true,
});
expect(html).not.toContain('data-slot="ecg-disclaimer"');
expect(html).not.toContain("Single-lead ECG strips");
});
});
+160
View File
@@ -0,0 +1,160 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { EcgWaveform } from "@/components/insights/ecg-waveform";
import { InsightSectionCard } from "@/components/insights/insight-section-card";
import {
ecgResultLabel,
isNonNormalEcg,
type EcgClassification,
} from "@/lib/insights/ecg-classification";
/**
* One ECG recording: the trace, the metadata, and the DEVICE's own result.
*
* NON-DIAGNOSTIC: the result shown is the recording device's, attributed to
* the device ("Recorded result: …, as reported by the recording device").
* HealthLog reads nothing off the curve. A non-normal device result adds the
* "discuss with a clinician" note. Plain React text children throughout.
*
* The component owns its own read, addressed by id, so the detail page needs
* nothing from the list it was opened from — a deep link works cold.
*/
export interface EcgDetailResponse {
recordedAt: string;
durationSeconds: number | null;
samplingFrequency: number;
averageHeartRate: number | null;
lead: string | null;
classification: EcgClassification;
source: string;
samples: number[];
decimated: boolean;
}
export function EcgDetail({ recordingId }: { recordingId: string }) {
const { t } = useTranslations();
const fmt = useFormatters();
const { data, isLoading, isError } = useQuery({
queryKey: queryKeys.insightsEcgDetail(recordingId, false),
queryFn: async () => {
try {
return await apiGet<EcgDetailResponse>(
`/api/insights/ecg/${recordingId}`,
);
} catch {
throw new Error(t("insights.ecg.loadError"));
}
},
});
if (isError) {
// A failed read must never read as "no data" (UI-STANDARDS §6).
return (
<p
data-slot="ecg-detail-error"
role="alert"
className="text-muted-foreground text-sm"
>
{t("insights.ecg.loadError")}
</p>
);
}
if (isLoading || !data) {
return (
<div
data-slot="ecg-waveform-skeleton"
className="bg-muted/40 h-40 w-full animate-pulse rounded-lg motion-reduce:animate-none"
/>
);
}
const resultLabel = ecgResultLabel(data.classification, t);
const leadLabel = data.lead ?? t("insights.ecg.meta.leadSingle");
const durationLabel =
data.durationSeconds != null
? t("insights.ecg.meta.durationValue", {
seconds: Math.round(data.durationSeconds),
})
: t("insights.ecg.meta.unknown");
return (
<InsightSectionCard slot="ecg-detail" className="border-border">
{/* The DEVICE's result, attributed to the device. This is the ONLY
verdict shown — HealthLog produces none. */}
{resultLabel && (
<p
data-slot="ecg-result"
className="text-foreground text-sm font-medium"
>
{t("insights.ecg.resultAttribution", { result: resultLabel })}
</p>
)}
<EcgWaveform
samples={data.samples}
recordedAt={data.recordedAt}
durationSeconds={data.durationSeconds}
averageHeartRate={data.averageHeartRate}
resultLabel={resultLabel}
/>
<dl
data-slot="ecg-meta"
className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3"
>
<MetaItem
label={t("insights.ecg.meta.recorded")}
value={fmt.dateTime(new Date(data.recordedAt))}
/>
<MetaItem
label={t("insights.ecg.meta.duration")}
value={durationLabel}
/>
<MetaItem label={t("insights.ecg.meta.lead")} value={leadLabel} />
<MetaItem
label={t("insights.ecg.meta.averageHeartRate")}
value={
data.averageHeartRate != null
? t("insights.ecg.meta.bpmValue", { bpm: data.averageHeartRate })
: t("insights.ecg.meta.unknown")
}
/>
<MetaItem
label={t("insights.ecg.meta.samplingRate")}
value={
data.samplingFrequency > 0
? t("insights.ecg.meta.hzValue", { hz: data.samplingFrequency })
: t("insights.ecg.meta.unknown")
}
/>
</dl>
{/* "Discuss with a clinician" — only on a non-normal device result. */}
{isNonNormalEcg(data.classification) && (
<p
data-slot="ecg-clinician-note"
className="text-foreground text-sm font-medium"
>
{t("insights.ecg.clinicianNote")}
</p>
)}
</InsightSectionCard>
);
}
function MetaItem({ label, value }: { label: string; value: string }) {
return (
<div className="space-y-0.5">
<dt className="text-muted-foreground text-xs">{label}</dt>
<dd className="text-foreground">{value}</dd>
</div>
);
}
+99 -279
View File
@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Activity, ChevronLeft, HeartPulse, Info } from "lucide-react";
import { Activity, ChevronRight, HeartPulse } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
@@ -10,35 +10,41 @@ import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/ui/section-heading";
import { EcgWaveform } from "@/components/insights/ecg-waveform";
import {
ecgResultLabel,
ECG_OVERVIEW_LIMIT,
type EcgClassification,
} from "@/lib/insights/ecg-classification";
import { InsightSectionCard } from "./insight-section-card";
// Re-exported so the tests (and any teaser call site) can keep importing the
// overview cap from the component they are exercising; the value itself lives
// in the React-free lib module.
export { ECG_OVERVIEW_LIMIT };
/**
* v1.28.50 — ECG recording surface (list → detail).
* v1.28.50 — ECG recording list.
*
* Renders the user's ECG recordings synced from a single-lead device
* (Withings ScanWatch today): a list of strips, each opening a detail view
* with the `EcgWaveform` trace, the recording metadata, and the DEVICE's
* own classification.
* The user's ECG recordings synced from a single-lead device (Withings
* ScanWatch today): one row per strip, each opening `/insights/ecg/<id>`
* with the trace, the metadata and the DEVICE's own classification.
*
* NON-DIAGNOSTIC (load-bearing, do not soften — mirrors `RhythmEventsCard`):
* the surface shows only the waveform (raw data), the metadata, and the
* recording device's own result, attributed unambiguously to the device
* ("Recorded result: …, as reported by the recording device"). HealthLog
* generates NO interpretation of the waveform — no measured intervals, no
* beat/P/QRS/T annotation, no risk score, no verdict of its own. A
* permanent, non-dismissible disclaimer states this, and any non-normal
* device result additionally carries a "discuss with a clinician" note.
* All copy renders as plain React text children (no markdown library — the
* standing XSS rule).
* NON-DIAGNOSTIC: the surface shows the waveform (raw data), the metadata
* and the recording device's own result, attributed to the device on the
* detail page. HealthLog generates NO interpretation of the waveform — no
* measured intervals, no beat/P/QRS/T annotation, no risk score, no verdict
* of its own. All copy renders as plain React text children (no markdown
* library — the standing XSS rule).
*
* The list is capped: a device that records every morning accumulates
* hundreds of strips, and a page that paints all of them is a scroll, not a
* history. The most recent handful is what the surface is for.
*
* Data-availability-gated: the section un-mounts entirely (`return null`)
* when the user has no recordings — never an empty / alarming card.
*/
type EcgClassification = "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null;
interface EcgRecordingListItem {
export interface EcgRecordingListItem {
id: string;
recordedAt: string;
durationSeconds: number | null;
@@ -56,30 +62,6 @@ interface EcgListResponse {
hasRecordings: boolean;
}
interface EcgDetailResponse {
recordedAt: string;
durationSeconds: number | null;
samplingFrequency: number;
averageHeartRate: number | null;
lead: string | null;
classification: EcgClassification;
source: string;
samples: number[];
decimated: boolean;
}
/** The device verdict, surfaced verbatim and attributed to the device. */
const RESULT_LABEL_KEYS: Record<string, string> = {
IRREGULAR: "insights.ecg.result.irregular",
NOT_DETECTED: "insights.ecg.result.notDetected",
INCONCLUSIVE: "insights.ecg.result.inconclusive",
};
/** Non-normal device results trigger the "discuss with a clinician" note. */
function isNonNormal(classification: EcgClassification): boolean {
return classification === "IRREGULAR" || classification === "INCONCLUSIVE";
}
interface EcgSectionProps {
enabled?: boolean;
className?: string;
@@ -87,21 +69,27 @@ interface EcgSectionProps {
* v1.30 — suppress the internal `<SectionHeading>` when the section is
* hosted on the routed `/insights/ecg` sub-page, whose `<SubPageShell>`
* already renders the page `<h1>`. Defaults to `false` so the overview
* teaser keeps its own heading unchanged. Purely presentational — the
* non-diagnostic disclaimer + waveform logic are untouched.
* teaser keeps its own heading unchanged.
*/
hideHeading?: boolean;
/**
* Cap the number of strips shown. The overview teaser passes
* `ECG_OVERVIEW_LIMIT`; the dedicated `/insights/ecg` page passes nothing,
* so it renders the full list. Omitted = no cap: a full surface must never
* silently hide a recording behind a limit with no way to reach it.
*/
limit?: number;
}
export function EcgSection({
enabled = true,
className,
hideHeading = false,
limit,
}: EcgSectionProps) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const fmt = useFormatters();
const [selectedId, setSelectedId] = useState<string | null>(null);
const { data, isLoading } = useQuery({
queryKey: queryKeys.insightsEcgList(),
@@ -118,16 +106,11 @@ export function EcgSection({
// Data-availability gate — never paint an empty card.
if (isLoading || !data || !data.hasRecordings) return null;
const selected =
selectedId != null
? (data.recordings.find((r) => r.id === selectedId) ?? null)
: null;
function resultLabel(classification: EcgClassification): string | null {
if (!classification) return null;
const key = RESULT_LABEL_KEYS[classification];
return key ? t(key) : null;
}
// The route hands them back most-recent-first. A `limit` (the overview
// teaser) keeps the newest few; no limit (the dedicated page) shows them
// all, so an older strip is never made unreachable.
const recordings =
limit != null ? data.recordings.slice(0, limit) : data.recordings;
return (
<section
@@ -143,230 +126,67 @@ export function EcgSection({
/>
)}
<InsightSectionCard slot="ecg-card" className="border-border">
{selected ? (
<EcgDetail
recording={selected}
resultLabel={resultLabel(selected.classification)}
onBack={() => setSelectedId(null)}
/>
) : (
<>
<p className="text-muted-foreground text-sm">
{t("insights.ecg.sectionIntro")}
</p>
<ol data-slot="ecg-list" className="space-y-3">
{data.recordings.map((rec) => {
const label = resultLabel(rec.classification);
const rowInner = (
<>
<span className="bg-muted text-muted-foreground mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
<HeartPulse className="size-4" />
</span>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-foreground text-sm font-medium">
{fmt.dateTime(new Date(rec.recordedAt))}
</p>
{label && (
<p
data-slot="ecg-row-result"
className="text-muted-foreground text-sm"
>
{label}
</p>
)}
{rec.averageHeartRate != null && (
<p className="text-muted-foreground text-xs">
{t("insights.ecg.meta.bpmValue", {
bpm: rec.averageHeartRate,
})}
</p>
)}
</div>
</>
);
return (
<li
key={rec.id}
data-slot="ecg-row"
data-classification={rec.classification ?? "NONE"}
className="border-border/60 border-b pb-3 last:border-b-0 last:pb-0"
>
{rec.hasWaveform ? (
<button
type="button"
onClick={() => setSelectedId(rec.id)}
className="hover:bg-muted/40 -m-1 flex w-full items-start gap-3 rounded-md p-1 text-left transition-colors"
<ol data-slot="ecg-list" className="space-y-3">
{recordings.map((rec) => {
const label = ecgResultLabel(rec.classification, t);
const rowInner = (
<>
<span className="bg-muted text-muted-foreground mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
<HeartPulse className="size-4" />
</span>
<div className="min-w-0 flex-1 space-y-0.5">
{/* The device's verdict rides the timestamp as a tag
rather than claiming a line of its own — it qualifies
the recording, it is not a second fact about it. */}
<div className="flex flex-wrap items-center gap-2">
<p className="text-foreground text-sm font-medium">
{fmt.dateTime(new Date(rec.recordedAt))}
</p>
{label && (
<span
data-slot="ecg-row-result"
className="border-border text-muted-foreground rounded-full border px-2 py-0.5 text-xs"
>
{rowInner}
</button>
) : (
<div className="flex items-start gap-3">{rowInner}</div>
{label}
</span>
)}
</li>
);
})}
</ol>
{/* Load-bearing regulatory disclaimer — permanent,
non-dismissible. Plain React text children (no markdown). */}
<EcgDisclaimer />
</>
)}
</div>
{rec.averageHeartRate != null && (
<p className="text-muted-foreground text-xs">
{t("insights.ecg.meta.bpmValue", {
bpm: rec.averageHeartRate,
})}
</p>
)}
</div>
</>
);
return (
<li
key={rec.id}
data-slot="ecg-row"
data-classification={rec.classification ?? "NONE"}
className="border-border/60 border-b pb-3 last:border-b-0 last:pb-0"
>
{rec.hasWaveform ? (
<Link
href={`/insights/ecg/${rec.id}`}
className="hover:bg-muted/40 -m-1 flex w-full items-start gap-3 rounded-md p-1 text-left transition-colors"
>
{rowInner}
<ChevronRight
className="text-muted-foreground mt-1.5 size-4 shrink-0"
aria-hidden="true"
/>
</Link>
) : (
<div className="flex items-start gap-3">{rowInner}</div>
)}
</li>
);
})}
</ol>
</InsightSectionCard>
</section>
);
}
/**
* The detail view: waveform + metadata + device-attributed result +
* permanent disclaimer + clinician note on a non-normal device result.
* Exported so the non-diagnostic framing can be unit-tested directly.
*/
export function EcgDetail({
recording,
resultLabel,
onBack,
}: {
recording: EcgRecordingListItem;
resultLabel: string | null;
onBack: () => void;
}) {
const { t } = useTranslations();
const fmt = useFormatters();
const { data: detail, isLoading } = useQuery({
queryKey: queryKeys.insightsEcgDetail(recording.id, false),
queryFn: async () => {
try {
return await apiGet<EcgDetailResponse>(
`/api/insights/ecg/${recording.id}`,
);
} catch {
throw new Error(t("insights.ecg.loadError"));
}
},
});
const nonNormal = isNonNormal(recording.classification);
const leadLabel = recording.lead ?? t("insights.ecg.meta.leadSingle");
const durationLabel =
recording.durationSeconds != null
? t("insights.ecg.meta.durationValue", {
seconds: Math.round(recording.durationSeconds),
})
: t("insights.ecg.meta.unknown");
return (
<div data-slot="ecg-detail" className="space-y-4">
<button
type="button"
onClick={onBack}
className="text-muted-foreground hover:text-foreground -ml-1 flex items-center gap-1 text-sm transition-colors"
>
<ChevronLeft className="size-4" />
{t("insights.ecg.backToList")}
</button>
{/* The DEVICE's result, attributed to the device. This is the ONLY
verdict shown — HealthLog produces none. */}
{resultLabel && (
<p
data-slot="ecg-result"
className="text-foreground text-sm font-medium"
>
{t("insights.ecg.resultAttribution", { result: resultLabel })}
</p>
)}
{isLoading || !detail ? (
<div
data-slot="ecg-waveform-skeleton"
className="bg-muted/40 h-40 w-full animate-pulse rounded-lg motion-reduce:animate-none"
/>
) : (
<EcgWaveform
samples={detail.samples}
recordedAt={detail.recordedAt}
durationSeconds={detail.durationSeconds}
averageHeartRate={detail.averageHeartRate}
resultLabel={resultLabel}
/>
)}
<dl
data-slot="ecg-meta"
className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3"
>
<MetaItem
label={t("insights.ecg.meta.recorded")}
value={fmt.dateTime(new Date(recording.recordedAt))}
/>
<MetaItem
label={t("insights.ecg.meta.duration")}
value={durationLabel}
/>
<MetaItem label={t("insights.ecg.meta.lead")} value={leadLabel} />
<MetaItem
label={t("insights.ecg.meta.averageHeartRate")}
value={
recording.averageHeartRate != null
? t("insights.ecg.meta.bpmValue", {
bpm: recording.averageHeartRate,
})
: t("insights.ecg.meta.unknown")
}
/>
<MetaItem
label={t("insights.ecg.meta.samplingRate")}
value={
recording.samplingFrequency > 0
? t("insights.ecg.meta.hzValue", {
hz: recording.samplingFrequency,
})
: t("insights.ecg.meta.unknown")
}
/>
</dl>
{/* "Discuss with a clinician" — only on a non-normal device result. */}
{nonNormal && (
<p
data-slot="ecg-clinician-note"
className="text-foreground text-sm font-medium"
>
{t("insights.ecg.clinicianNote")}
</p>
)}
<EcgDisclaimer />
</div>
);
}
function MetaItem({ label, value }: { label: string; value: string }) {
return (
<div className="space-y-0.5">
<dt className="text-muted-foreground text-xs">{label}</dt>
<dd className="text-foreground">{value}</dd>
</div>
);
}
/**
* The permanent, non-dismissible non-diagnostic disclaimer. States that the
* displayed result is the device's and that HealthLog does not read,
* interpret, or diagnose ECG recordings.
*/
function EcgDisclaimer() {
const { t } = useTranslations();
return (
<div
data-slot="ecg-disclaimer"
role="note"
className="bg-muted/50 text-muted-foreground flex items-start gap-2 rounded-lg p-3 text-xs"
>
<Info className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<p>{t("insights.ecg.disclaimer")}</p>
</div>
);
}
@@ -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 }) => {
@@ -132,6 +132,7 @@ const OWNER_READ: AccountAccessEntry = {
accountId: "acct-owner",
username: "grandma",
displayName: "Margarethe",
fullName: null,
access: "read" as const,
level: "read" as const,
sections: null,
@@ -148,6 +149,7 @@ const MANAGED_GUARDIAN: AccountAccessEntry = {
accountId: "managed-record",
username: "managed-record",
displayName: "Managed profile",
fullName: null,
access: "write" as const,
level: "manage" as const,
sections: null,
@@ -56,6 +56,7 @@ const OWNER = {
accountId: "acct-owner",
username: "grandma",
displayName: "Margarethe",
fullName: null,
access: "read" as const,
level: "read" as const,
sections: null,
@@ -108,6 +109,25 @@ describe("<SharedRecordBanner>", () => {
expect(html).toContain("grandma");
});
it("names the owner in full when they have a full name, to a read-only delegate", () => {
// v1.37.2 — the banner is the account-access view that renders server-side,
// and this OWNER is READ (`canWrite: false`). The full name leads over the
// greeting name: a delegate about to log a reading sees whose record it is
// as who they are, not by a nickname. A deliberate disclosure to everyone
// shared with, read-only included (maintainer decision 2026-08-08).
const named = {
...OWNER,
fullName: "Test Full Name",
displayName: "Margarethe",
};
const html = render(
{ accounts: [named], active: named, canSwitch: true },
<SharedRecordBanner />,
);
expect(html).toContain('data-slot="shared-record-banner"');
expect(html).toContain("Test Full Name");
});
it("says the access is read-only, and carries the way out", () => {
const html = render(
{ accounts: [OWNER], active: OWNER, canSwitch: true },
@@ -101,11 +101,15 @@ export function AccountSwitcherMenuItems() {
)
}
>
{/* Two lines, not three. The access level had a line of its
own here and it never fit — a 240 px menu truncated
"Kann verwalten" as often as it showed it, and the entry
still said which kind of record this is on the line below.
The level is what the banner states once you are inside,
where it governs what the page lets you do. It stays on
the row as `data-access-level` for the browser journeys. */}
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate">{accountLabel(account)}</span>
<span className="text-muted-foreground truncate text-xs">
{accessLabel(presentation.access, t)}
</span>
<span className="text-muted-foreground truncate text-xs">
{recordKindLabel(presentation.recordKind, t)}
</span>
@@ -122,16 +126,6 @@ export function AccountSwitcherMenuItems() {
);
}
function accessLabel(
access: ReturnType<typeof resolveRecordPresentation>["access"],
t: ReturnType<typeof useTranslations>["t"],
): string {
if (access === "manage") return t("recordSharing.row.canManage");
return access === "view-and-add"
? t("recordSharing.switcher.canWrite")
: t("recordSharing.switcher.readOnly");
}
function recordKindLabel(
recordKind: ReturnType<typeof resolveRecordPresentation>["recordKind"],
t: ReturnType<typeof useTranslations>["t"],
@@ -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>
@@ -24,7 +24,7 @@ import { I18nProvider } from "@/lib/i18n/context";
// The Radix Dialog portals at runtime, so its body never materialises in
// static markup. Collapse the primitives to plain wrappers (same trick as
// the ResearchModeAcknowledgmentDialog suite) so the footer is reachable.
// the dose-history dialog suite) so the footer is reachable.
vi.mock("@/components/ui/dialog", () => {
const Pass = ({ children }: { children?: React.ReactNode }) => (
<div>{children}</div>
@@ -1,291 +0,0 @@
/**
* v1.4.25 W19c-Frontend — Research-mode acknowledgment dialog tests.
*
* The dialog is rendered controlled by Settings (commit 3) and gates
* the GLP-1 drug-level chart (commit 2). The contract is:
*
* 1. Multi-section MDR copy renders (what-is, what-isn't, why,
* MDR boundary, citations).
* 2. Acknowledge CTA fires a `POST /api/auth/me/research-mode` with
* the exact server-supplied version string.
* 3. Cancel CTA fires `onOpenChange(false)` without an API call.
* 4. The stale-version error (server 400 with
* `research-mode.version.stale`) surfaces a localised error toast
* and keeps the dialog open.
*
* Setup mirrors the existing `coach-settings-sheet.test.tsx` pattern:
* SSR via `renderToStaticMarkup` so the Radix Dialog primitives are
* mocked to plain wrappers (Portals don't materialise under SSR), and
* the TanStack Query mutation is captured so we can drive the
* onSuccess / onError branches by hand.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { toast } from "sonner";
import { I18nProvider } from "@/lib/i18n/context";
const mutationCallbacks: {
mutationFn: ((version: string) => Promise<unknown>) | null;
onSuccess: ((data: unknown) => void) | null;
onError: ((err: unknown) => void) | null;
} = { mutationFn: null, onSuccess: null, onError: null };
const lastMutateCall: { args: unknown[] } = { args: [] };
vi.mock("@tanstack/react-query", () => ({
useMutation: (opts: {
mutationFn: (version: string) => Promise<unknown>;
onSuccess?: (data: unknown) => void;
onError?: (err: unknown) => void;
}) => {
mutationCallbacks.mutationFn = opts.mutationFn;
mutationCallbacks.onSuccess = opts.onSuccess ?? null;
mutationCallbacks.onError = opts.onError ?? null;
return {
mutate: (version: string) => {
lastMutateCall.args = [version];
},
isPending: false,
};
},
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
// v1.4.27 R4 RC2 — the dialog now mounts <ResponsiveSheet>. Its
// internals (Sheet on `<md`, Dialog on `md+`) both portal at runtime;
// we collapse the primitive to a plain wrapper so the body of the
// dialog stays reachable in the static markup.
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({
open,
title,
description,
footer,
children,
}: {
open: boolean;
title: React.ReactNode;
description?: React.ReactNode;
footer?: React.ReactNode;
children: React.ReactNode;
}) =>
open ? (
<div data-slot="mock-responsive-sheet">
<div data-slot="mock-responsive-sheet-header">
<h2 data-slot="mock-responsive-sheet-title">{title}</h2>
{description ? (
<p data-slot="mock-responsive-sheet-description">{description}</p>
) : null}
</div>
<div data-slot="mock-responsive-sheet-body">{children}</div>
{footer ? (
<div data-slot="mock-responsive-sheet-footer">{footer}</div>
) : null}
</div>
) : null,
}));
import { ResearchModeAcknowledgmentDialog } from "../research-mode-acknowledgment-dialog";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
beforeEach(() => {
mutationCallbacks.mutationFn = null;
mutationCallbacks.onSuccess = null;
mutationCallbacks.onError = null;
lastMutateCall.args = [];
vi.mocked(toast.success).mockClear();
vi.mocked(toast.error).mockClear();
});
describe("<ResearchModeAcknowledgmentDialog>", () => {
it("renders nothing while `open === false`", () => {
const html = render(
<ResearchModeAcknowledgmentDialog
open={false}
onOpenChange={() => {}}
currentDisclaimerVersion="2026-05-14.1"
/>,
);
expect(html).toBe("");
});
it("renders the title + the five MDR-copy sections while open", () => {
const html = render(
<ResearchModeAcknowledgmentDialog
open={true}
onOpenChange={() => {}}
currentDisclaimerVersion="2026-05-14.1"
/>,
);
// Title + primary CTAs.
expect(html).toContain("Enable Research Mode");
expect(html).toContain("I understand — enable");
expect(html).toContain("Cancel");
// Each of the five sections renders its own headline.
expect(html).toContain("What this is");
expect(html).toContain("What this isn&#x27;t");
expect(html).toContain("Why it&#x27;s an estimate");
expect(html).toContain("Regulatory boundary");
expect(html).toContain("Sources");
// Cited regulatory references must appear verbatim in the
// user-facing copy (project-voice + research §11 + §12.4).
expect(html).toContain("2017/745");
expect(html).toContain("MDCG 2021-24");
// Peer-reviewed citation.
expect(html).toContain("10.1002/psp4.13099");
// Version line surfaces the live server value.
expect(html).toContain("Disclaimer version: 2026-05-14.1");
});
it("acknowledge CTA invokes the mutation with the supplied version", async () => {
render(
<ResearchModeAcknowledgmentDialog
open={true}
onOpenChange={() => {}}
currentDisclaimerVersion="2026-05-14.1"
/>,
);
// SSR markup can't fire DOM events; invoke the mutation directly
// (the production component calls `mutation.mutate(version)` from
// the click handler with `currentDisclaimerVersion`).
expect(mutationCallbacks.mutationFn).not.toBeNull();
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
headers: new Headers(),
json: async () => ({ data: { enabled: true } }),
// The apiFetch wrapper reads the envelope via `text()`.
text: async () => JSON.stringify({ data: { enabled: true } }),
});
vi.stubGlobal("fetch", fetchMock);
await mutationCallbacks.mutationFn?.("2026-05-14.1");
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("/api/auth/me/research-mode");
expect(init.method).toBe("POST");
expect(JSON.parse(init.body as string)).toEqual({
acknowledged: true,
version: "2026-05-14.1",
});
vi.unstubAllGlobals();
});
it("onSuccess fires the localised success toast", () => {
const onAcknowledged = vi.fn();
const onOpenChange = vi.fn();
render(
<ResearchModeAcknowledgmentDialog
open={true}
onOpenChange={onOpenChange}
currentDisclaimerVersion="2026-05-14.1"
onAcknowledged={onAcknowledged}
/>,
);
mutationCallbacks.onSuccess?.({ enabled: true });
expect(vi.mocked(toast.success)).toHaveBeenCalledTimes(1);
expect(vi.mocked(toast.success).mock.calls[0][0]).toContain(
"Research Mode enabled",
);
expect(onAcknowledged).toHaveBeenCalledTimes(1);
expect(onOpenChange).toHaveBeenCalledWith(false);
});
it("stale-version error surfaces the localised error toast and keeps the dialog open", () => {
const onOpenChange = vi.fn();
render(
<ResearchModeAcknowledgmentDialog
open={true}
onOpenChange={onOpenChange}
currentDisclaimerVersion="2026-05-14.1"
/>,
);
mutationCallbacks.onError?.(new Error("research-mode.version.stale"));
expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1);
expect(vi.mocked(toast.error).mock.calls[0][0]).toContain(
"disclaimer was updated",
);
// The error path never closes the dialog — the caller stays put
// so the user can reload and re-read the new copy.
expect(onOpenChange).not.toHaveBeenCalled();
});
it("generic error path surfaces the generic error toast", () => {
render(
<ResearchModeAcknowledgmentDialog
open={true}
onOpenChange={() => {}}
currentDisclaimerVersion="2026-05-14.1"
/>,
);
mutationCallbacks.onError?.(new Error("http-500"));
expect(vi.mocked(toast.error)).toHaveBeenCalledTimes(1);
expect(vi.mocked(toast.error).mock.calls[0][0]).toContain(
"Could not enable Research Mode",
);
});
it("acknowledge CTA is disabled until the server-supplied version arrives", () => {
const html = render(
<ResearchModeAcknowledgmentDialog
open={true}
onOpenChange={() => {}}
currentDisclaimerVersion={null}
/>,
);
// The disabled flag stamps as `disabled=""` on a server-rendered button.
// The acknowledge button carries `data-slot="research-mode-acknowledge"`.
const acknowledgeFragment = html.match(
/<button[^>]*data-slot="research-mode-acknowledge"[^>]*>/,
)?.[0];
expect(acknowledgeFragment).toBeDefined();
expect(acknowledgeFragment).toMatch(/\sdisabled(="|\s|>)/);
// The version line is hidden when null — guard against leaking
// `version: null` text into the UI.
expect(html).not.toContain("Disclaimer version:");
});
it("renders German copy when locale='de'", () => {
const html = render(
<ResearchModeAcknowledgmentDialog
open={true}
onOpenChange={() => {}}
currentDisclaimerVersion="2026-05-14.1"
/>,
"de",
);
expect(html).toContain("Forschungsmodus aktivieren");
expect(html).toContain("Verstanden — aktivieren");
expect(html).toContain("Abbrechen");
expect(html).toContain("2017/745");
});
});
@@ -1,264 +0,0 @@
"use client";
/**
* v1.4.25 W19c-Frontend — Research-mode MDR acknowledgment dialog.
*
* Opt-in gate in front of the GLP-1 drug-level chart. The chart paints
* a unit-less Bateman PK curve over the user's logged intakes (research
* §2.3 / §9.6.7). Because a "drug-level" surface is the brightest EU
* MDR Class IIa edge in HealthLog's surface area (research §11 + §12.4),
* the chart never paints until the user has explicitly read and
* acknowledged the disclaimer below. The acknowledgment record is
* server-stamped with `RESEARCH_MODE_DISCLAIMER_VERSION`; if the copy
* changes the user re-acknowledges before the chart unhides.
*
* The dialog is rendered controlled by the Settings toggle and the
* "re-prompt" banner (commit 3 of this phase). It accepts:
* - `open` / `onOpenChange` for full external control
* - `currentDisclaimerVersion` — the server's authoritative version
* string. Acknowledgment posts THIS value, not the imported
* constant — server redeploys that bump the constant must be
* reflected without a client reload (W19c-Backend phase report).
* - `onAcknowledged()` — fires after a successful 200; the caller
* re-fetches the research-mode state and renders the toggle in
* the new state.
*
* Copy direction (project-voice, English, no AI/phase/wave mentions):
* - "What this is" — single-paragraph framing: the chart is an
* estimate from EMA-published population PK.
* - "What this isn't" — explicit boundary: not a measurement, not
* personalised, not medical advice.
* - "Why it's an estimate" — population PK + IIV; per-patient
* variation is large.
* - "MDR boundary" — cite EU 2017/745 and MDCG 2021-24 verbatim.
* - "Citations" — EMA EPAR + Schneck/Urva 2024 (ASCPT psp4.13099).
*
* Toast surface uses sonner (the project convention) so the user gets
* a transient confirmation on either success or rejection.
*/
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { AlertTriangle, BookOpenCheck, Loader2 } from "lucide-react";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiPost } from "@/lib/api/api-fetch";
export interface ResearchModeAcknowledgmentDialogProps {
open: boolean;
onOpenChange: (next: boolean) => void;
/**
* The version string from `GET /api/auth/me/research-mode`. The
* server compares it byte-for-byte against the current constant; a
* stale value forces a 400 from the API which we surface as an
* error toast and keep the dialog open. Pass `null` while the
* GET is in flight; the Acknowledge CTA stays disabled.
*/
currentDisclaimerVersion: string | null;
/**
* Fires after the POST returns 200. Caller invalidates the
* `research-mode` query so the toggle reflects the new state.
*/
onAcknowledged?: () => void;
}
export function ResearchModeAcknowledgmentDialog({
open,
onOpenChange,
currentDisclaimerVersion,
onAcknowledged,
}: ResearchModeAcknowledgmentDialogProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const mutation = useMutation({
mutationFn: async (version: string) => {
// ApiError.message carries the envelope `error` string verbatim, so
// the stale-version code below still matches on `err.message`.
return (
(await apiPost("/api/auth/me/research-mode", {
acknowledged: true,
version,
})) ?? null
);
},
onSuccess: () => {
setErrorMessage(null);
toast.success(t("medications.researchMode.dialog.successToast"));
queryClient.invalidateQueries({ queryKey: queryKeys.researchMode() });
onAcknowledged?.();
onOpenChange(false);
},
onError: (err: unknown) => {
// The 400 stale-version path is the only one we surface
// explicitly — every other failure mode (401, 429, 500, network)
// collapses to a generic error so the dialog stays open but the
// user knows the acknowledgment didn't persist.
const code = err instanceof Error ? err.message : "unknown";
const isStale = code === "research-mode.version.stale";
const message = isStale
? t("medications.researchMode.dialog.staleVersionToast")
: t("medications.researchMode.dialog.errorToast");
setErrorMessage(message);
toast.error(message);
},
});
function handleAcknowledge() {
if (!currentDisclaimerVersion) return;
mutation.mutate(currentDisclaimerVersion);
}
function handleCancel() {
setErrorMessage(null);
onOpenChange(false);
}
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={
<span className="flex items-center gap-2">
<BookOpenCheck className="text-foreground h-5 w-5 shrink-0" />
{t("medications.researchMode.dialog.title")}
</span>
}
description={t("medications.researchMode.dialog.intro")}
className="sm:max-w-xl"
footer={
<>
<Button
variant="outline"
onClick={handleCancel}
disabled={mutation.isPending}
data-slot="research-mode-cancel"
>
{t("medications.researchMode.dialog.cancelCta")}
</Button>
<Button
onClick={handleAcknowledge}
disabled={mutation.isPending || !currentDisclaimerVersion}
data-slot="research-mode-acknowledge"
>
{mutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("medications.researchMode.dialog.acknowledgeCta")}
</Button>
</>
}
>
<div
data-slot="research-mode-acknowledgment-dialog"
className="space-y-4 text-sm"
>
<section
aria-labelledby="research-mode-what-it-is"
data-slot="research-mode-what-it-is"
>
<h3
id="research-mode-what-it-is"
className="text-foreground font-medium"
>
{t("medications.researchMode.dialog.whatItIsHeader")}
</h3>
<p className="text-muted-foreground mt-1">
{t("medications.researchMode.dialog.whatItIs")}
</p>
</section>
<section
aria-labelledby="research-mode-what-it-isnt"
data-slot="research-mode-what-it-isnt"
>
<h3
id="research-mode-what-it-isnt"
className="text-foreground font-medium"
>
{t("medications.researchMode.dialog.whatItIsntHeader")}
</h3>
<p className="text-muted-foreground mt-1">
{t("medications.researchMode.dialog.whatItIsnt")}
</p>
</section>
<section
aria-labelledby="research-mode-why-estimate"
data-slot="research-mode-why-estimate"
>
<h3
id="research-mode-why-estimate"
className="text-foreground font-medium"
>
{t("medications.researchMode.dialog.whyEstimateHeader")}
</h3>
<p className="text-muted-foreground mt-1">
{t("medications.researchMode.dialog.whyEstimate")}
</p>
</section>
<section
aria-labelledby="research-mode-mdr"
data-slot="research-mode-mdr-boundary"
className="bg-warning/10 border-warning/30 rounded-md border-l-4 px-3 py-2"
>
<h3
id="research-mode-mdr"
className="text-foreground flex items-center gap-2 font-medium"
>
<AlertTriangle
className="text-warning h-4 w-4 shrink-0"
aria-hidden="true"
/>
{t("medications.researchMode.dialog.mdrBoundaryHeader")}
</h3>
<p className="text-foreground/85 mt-1">
{t("medications.researchMode.dialog.mdrBoundary")}
</p>
</section>
<section
aria-labelledby="research-mode-citations"
data-slot="research-mode-citations"
>
<h3
id="research-mode-citations"
className="text-foreground font-medium"
>
{t("medications.researchMode.dialog.citationsHeader")}
</h3>
<p className="text-muted-foreground mt-1">
{t("medications.researchMode.dialog.citations")}
</p>
</section>
{currentDisclaimerVersion && (
<p
className="text-muted-foreground text-xs italic"
data-slot="research-mode-version"
>
{t("medications.researchMode.dialog.versionLine", {
version: currentDisclaimerVersion,
})}
</p>
)}
{errorMessage && (
<p
role="alert"
className="text-destructive text-sm"
data-slot="research-mode-error"
>
{errorMessage}
</p>
)}
</div>
</ResponsiveSheet>
);
}
@@ -142,6 +142,17 @@ export function MentalWellbeing() {
description={t("mentalHealth.pageDescription")}
/>
{/* What happens to the answers. The Modules hub used to carry this
as the second sentence of the module's one-line description,
which was the only place in the app that said it — so it moved
to the screen where somebody is about to answer. */}
<p
data-slot="mental-health-privacy-note"
className="text-muted-foreground -mt-3 text-xs"
>
{t("mentalHealth.privacyNote")}
</p>
<section aria-label={t("mentalHealth.choosePrompt")}>
<h2 className="sr-only">{t("mentalHealth.choosePrompt")}</h2>
{historyError ? (
@@ -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>
);
}
@@ -29,10 +29,6 @@ vi.mock("@tanstack/react-query", () => ({
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/components/medications/research-mode-acknowledgment-dialog", () => ({
ResearchModeAcknowledgmentDialog: () => <div data-slot="mock-ack-dialog" />,
}));
import { AdvancedSection } from "../advanced-section";
function render(locale: "en" | "de" = "en") {
@@ -1,189 +0,0 @@
/**
* v1.4.25 W19c-Frontend — `<AdvancedSection>` Research Mode card tests.
*
* The card lives next to the danger-zone delete and drives three
* server interactions:
*
* GET /api/auth/me/research-mode on mount, via TanStack Query
* POST /api/auth/me/research-mode via the acknowledgment dialog
* DELETE /api/auth/me/research-mode directly when the user
* toggles OFF
*
* The toggle ON path opens the dialog; only the dialog's CTA fires
* the POST (so the user reads the disclaimer first). OFF fires
* DELETE directly. A version-mismatch banner renders above the
* toggle whenever `acknowledgedVersion !== currentDisclaimerVersion`
* even when `enabled === true`.
*
* The dialog itself is covered by
* `research-mode-acknowledgment-dialog.test.tsx`; we mock it down to a
* marker here so the toggle behaviour is the only contract under
* test.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
const queryResults: Record<string, unknown> = {};
function setQueryResult(keyJoined: string, data: unknown) {
queryResults[keyJoined] = data;
}
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
const key = (queryKey as Array<string | number>).join("/");
return {
data: queryResults[key],
isLoading: false,
};
},
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}));
// The acknowledgment dialog is exercised in its own spec; mock the
// import to a transparent marker so we can assert "the toggle ON path
// renders the dialog with the right props" without re-running the
// dialog tree.
vi.mock("@/components/medications/research-mode-acknowledgment-dialog", () => ({
ResearchModeAcknowledgmentDialog: ({
open,
currentDisclaimerVersion,
}: {
open: boolean;
currentDisclaimerVersion: string | null;
}) => (
<div
data-slot="mock-ack-dialog"
data-open={open ? "true" : "false"}
data-version={currentDisclaimerVersion ?? ""}
/>
),
}));
import { AdvancedSection } from "../advanced-section";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
beforeEach(() => {
for (const k of Object.keys(queryResults)) delete queryResults[k];
});
describe("<AdvancedSection> → Research Mode card", () => {
it("renders the section title + subtitle + toggle in OFF state", () => {
setQueryResult("research-mode", {
enabled: false,
acknowledgedAt: null,
acknowledgedVersion: null,
currentDisclaimerVersion: "2026-05-14.1",
});
const html = render(<AdvancedSection />);
expect(html).toContain('data-slot="settings-research-mode-card"');
expect(html).toContain("Research Mode");
expect(html).toContain("Show the estimated drug-level chart");
// OFF state status line.
expect(html).toContain("Disabled. The drug-level chart is hidden.");
// No re-prompt banner when versions already align (or the user has
// never acknowledged in the first place).
expect(html).not.toContain('data-slot="settings-research-mode-reprompt"');
// Switch is unchecked.
const switchTag = html.match(
/<button[^>]*data-slot="settings-research-mode-toggle"[^>]*>/,
)?.[0];
expect(switchTag).toBeDefined();
expect(switchTag).not.toMatch(/data-state="checked"/);
});
it("renders the acknowledged-on status when enabled + versions aligned", () => {
setQueryResult("research-mode", {
enabled: true,
acknowledgedAt: "2026-05-14T08:30:00Z",
acknowledgedVersion: "2026-05-14.1",
currentDisclaimerVersion: "2026-05-14.1",
});
const html = render(<AdvancedSection />);
// Acknowledged-on copy carries the formatted date (the i18n
// template uses `{date}`; the formatter renders the day).
expect(html).toMatch(/Acknowledged on/);
// Banner stays hidden when versions match.
expect(html).not.toContain('data-slot="settings-research-mode-reprompt"');
// Switch is checked.
const switchTag = html.match(
/<button[^>]*data-slot="settings-research-mode-toggle"[^>]*>/,
)?.[0];
expect(switchTag).toMatch(/data-state="checked"/);
});
it("renders the re-prompt banner when versions diverge", () => {
setQueryResult("research-mode", {
enabled: true,
acknowledgedAt: "2026-04-01T00:00:00Z",
acknowledgedVersion: "2026-04-01.1",
currentDisclaimerVersion: "2026-05-14.1",
});
const html = render(<AdvancedSection />);
expect(html).toContain('data-slot="settings-research-mode-reprompt"');
expect(html).toContain("Disclaimer updated");
expect(html).toContain('data-slot="settings-research-mode-reprompt-cta"');
expect(html).toContain("Re-acknowledge disclaimer");
// Status line reflects the stale state without claiming
// "acknowledged on …" — that copy would mis-represent that the
// chart is currently painting.
expect(html).not.toMatch(/Acknowledged on/);
expect(html).toContain(
"The disclaimer was updated. Re-acknowledge below to bring the chart back.",
);
// The toggle stays "on" — we surface the previous choice; the
// banner explains why the chart isn't painting.
const switchTag = html.match(
/<button[^>]*data-slot="settings-research-mode-toggle"[^>]*>/,
)?.[0];
expect(switchTag).toMatch(/data-state="checked"/);
});
it("forwards the current server-supplied version to the dialog", () => {
setQueryResult("research-mode", {
enabled: false,
acknowledgedAt: null,
acknowledgedVersion: null,
currentDisclaimerVersion: "2026-06-01.2",
});
const html = render(<AdvancedSection />);
// The dialog renders even when closed (its `open` prop is what
// controls visibility); the data-version attribute lets us verify
// the parent passes the live version string through.
expect(html).toContain('data-slot="mock-ack-dialog"');
expect(html).toMatch(/data-version="2026-06-01\.2"/);
expect(html).toMatch(/data-open="false"/);
});
it("renders German copy when locale='de'", () => {
setQueryResult("research-mode", {
enabled: false,
acknowledgedAt: null,
acknowledgedVersion: null,
currentDisclaimerVersion: "2026-05-14.1",
});
const html = render(<AdvancedSection />, "de");
expect(html).toContain("Forschungsmodus");
expect(html).toContain("Geschätzte Wirkstoffkurve anzeigen");
expect(html).toContain("Deaktiviert");
});
});
@@ -0,0 +1,77 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { MODULE_KEYS } from "@/lib/modules/registry";
import { locales } from "@/lib/i18n/config";
/**
* A module row in the "What you track" hub gets ONE sentence, same as a card
* header's description slot (design standards §3).
*
* Five of the eighteen rows had grown a second sentence — always the same
* one, "Off by default", and in one case a third clause after it. The
* neighbours sat at 30-75 characters and those five at 120-165, so the hub
* read as two different lists stacked on each other. The switch beside the
* row already says whether the module is on, so the sentence was restating
* what the control shows.
*
* The single-sentence half of the rule is checked in every locale: it is a
* structural claim, not a length one, and a translation that re-adds the
* clause is the same defect. The character cap is checked in EN and DE, the
* two the maintainer reads, matching `card-description-length.test.ts`.
*/
const ROOT = process.cwd();
const MAX_CHARS = 120;
function bundle(locale: string): Record<string, { description?: string }> {
const raw = readFileSync(join(ROOT, "messages", `${locale}.json`), "utf8");
return JSON.parse(raw).modules;
}
describe("module hub descriptions", () => {
it("names a description for every module in every locale", () => {
// Positive control: without it, a renamed `modules` namespace would make
// the two checks below iterate an empty set and report green.
for (const locale of locales) {
const modules = bundle(locale);
const missing = MODULE_KEYS.filter(
(key) => !modules[key]?.description?.trim(),
);
expect(missing, `${locale} is missing descriptions`).toEqual([]);
}
});
it("holds each one to a single sentence, in every locale", () => {
const offenders: string[] = [];
for (const locale of locales) {
const modules = bundle(locale);
for (const key of MODULE_KEYS) {
const text = modules[key].description!;
// Drop the closing terminator, then any sentence break left inside
// is a second sentence. A semicolon splice counts — it is how the
// longest of the five carried its third clause.
const body = text.replace(/[.!?…]\s*$/, "");
if (/[.!?;]\s/.test(body)) {
offenders.push(`${locale}/${key}: ${text}`);
}
}
}
expect(offenders).toEqual([]);
});
it("holds each one under the character guideline in EN and DE", () => {
const offenders: string[] = [];
for (const locale of ["en", "de"] as const) {
const modules = bundle(locale);
for (const key of MODULE_KEYS) {
const text = modules[key].description!;
if (text.length > MAX_CHARS) {
offenders.push(`${locale}/${key}: ${text.length} chars`);
}
}
}
expect(offenders).toEqual([]);
});
});
@@ -10,7 +10,7 @@
* canonical column (`disableCoach` / `cycleTrackingEnabled`) via the existing
* per-column endpoints — never the module-allowlist the gate ignores for them.
*
* Test strategy mirrors `advanced-research-mode.test.tsx`: mock
* Test strategy mirrors `advanced-account-delete.test.tsx`: mock
* `@tanstack/react-query` so the rendered tree executes under SSR
* (`renderToStaticMarkup`), capture the `useMutation` config to assert the
* PATCH body + the `authMe()` invalidation directly, and mock `useAuth` to
@@ -163,11 +163,20 @@ describe("<ModulesSection>", () => {
}
});
it("keeps a manage deep-link beside the delegated coach + cycle switches", () => {
it("keeps a manage deep-link beside the delegated coach switch", () => {
const html = render();
// Coach → Coach settings; cycle → the Account cycle-tracking card.
// Coach → Coach settings. Coach is the only delegated module with more
// to configure than on/off (cadence, memory) at a settings address.
expect(html).toContain('href="/settings/coach"');
expect(html).toContain('href="/settings/account#cycle-tracking"');
});
it("offers no Account deep-link beside the cycle switch", () => {
// Cycle used to carry a second enable surface in Settings → Account,
// and this link pointed at it. The switch here drives the same column,
// so the Account card went and the link with it — a deep-link to an
// anchor nothing renders is a door into a wall.
const html = render();
expect(html).not.toContain("/settings/account#cycle-tracking");
});
it("no longer renders the 'Always on' core-domains card", () => {
@@ -67,6 +67,20 @@ describe("SettingsCard — the container owns the rhythm", () => {
const html = render(<SettingsCard className="gap-2">body</SettingsCard>);
expect(html).toContain("gap-2");
});
it("drops the gap as well as the padding on a flush ledger card", () => {
// Dropping only the padding leaves the card's own gap standing between
// the rows as dead space the first row does not have. Both breakpoints
// are named because the `<Card>` primitive carries a `md:` gap that a
// bare `gap-0` loses to.
const html = render(<SettingsCard flush>rows</SettingsCard>);
expect(html).toContain("gap-0");
expect(html).toContain("md:gap-0");
expect(html).toContain("p-0");
expect(html).toContain("md:p-0");
expect(html).not.toMatch(/class="[^"]*\bgap-4\b/);
expect(html).not.toMatch(/class="[^"]*\bp-4\b/);
});
});
describe("SettingsCardHeader — one description sentence, one text run", () => {
@@ -0,0 +1,108 @@
/**
* Where the change-password card lives.
*
* It used to sit at the bottom of Settings → Account, under the profile form.
* Changing a password is the same kind of decision as enrolling a second
* factor, so it moved to Settings → Security. Two things have to hold for
* that move to be real rather than a duplicate:
*
* 1. Security mounts it — and mounts it BEFORE the factor status resolves,
* because it is now the only way to change a password and must not
* depend on a query it does not need.
* 2. Account does not, and no second copy of the dialog exists anywhere.
*
* The second claim is checked against the source tree rather than a render,
* because a render of `<AccountSection>` proves the card is absent from the
* one branch the test happened to reach. Sweeping the files proves there is
* exactly one place in the app that can paint it.
*/
import { describe, it, expect, vi } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: undefined, isLoading: true }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: null,
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { PasswordCard } from "../password-card";
import { SecuritySection } from "../security-section";
const SLOT = 'data-slot="settings-password-card"';
function render(node: React.ReactNode): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="de">{node}</I18nProvider>,
);
}
const ROOT = process.cwd();
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
if (entry === "__tests__") continue;
walk(full, out);
} else if (entry.endsWith(".tsx")) {
out.push(full);
}
}
return out;
}
describe("change-password card placement", () => {
it("paints its slot", () => {
// Positive control for the two assertions below — without it, a renamed
// slot would make every "is it here / is it gone" check read green.
expect(render(<PasswordCard />)).toContain(SLOT);
});
it("is mounted by Settings → Security before the factor status lands", () => {
// The mocked query never resolves, which is the case that matters: the
// MFA read is still in flight (or failing) and the password card is
// reachable regardless.
const html = render(<SecuritySection />);
expect(html).toContain(SLOT);
});
it("has exactly one home in the tree", () => {
const owners = walk(join(ROOT, "src"))
.filter((file) => readFileSync(file, "utf8").includes("PasswordCard"))
.map((file) => file.slice(ROOT.length + 1))
.sort();
expect(owners).toEqual([
"src/components/settings/password-card.tsx",
"src/components/settings/security-section/index.tsx",
]);
});
it("left no password form behind in the Account section", () => {
const account = readFileSync(
join(ROOT, "src/components/settings/account-section/index.tsx"),
"utf8",
);
for (const marker of [
"settings.passwordReset",
"settings.changePassword",
"/api/auth/password",
"current-password",
]) {
expect(account, `Account still carries ${marker}`).not.toContain(marker);
}
});
});
@@ -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");
@@ -213,6 +213,7 @@ function entry(partial: Partial<AccountAccessEntry> = {}): AccountAccessEntry {
accountId: "p1",
username: "managed-abc",
displayName: "Managed record",
fullName: null,
access: "write",
level: "manage",
recordKind: "managed",
@@ -2,11 +2,10 @@
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Save, Shield, User } from "lucide-react";
import { Loader2, Save, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
SeededFormDiscardDialog,
useSeededFormDismissal,
@@ -15,7 +14,6 @@ import { Input } from "@/components/ui/input";
import { DateField } from "@/components/ui/date-field";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { PasswordInput } from "@/components/ui/password-input";
import { HeightFieldControl } from "@/components/profile/height-field-control";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
@@ -35,7 +33,6 @@ import { storeTimezone } from "@/lib/timezone-mirror";
import { TimeFormatSelect } from "@/components/settings/time-format-select";
import { DateFormatSelect } from "@/components/settings/date-format-select";
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card";
import { detectBrowserTimezone } from "@/lib/tz/format";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import {
@@ -94,17 +91,6 @@ export function AccountSection() {
"success" | "warning" | "error" | null
>(null);
// Password change dialog state.
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [passwordSaving, setPasswordSaving] = useState(false);
const [passwordMsg, setPasswordMsg] = useState<StatusMessage | null>(null);
const [passwordMsgType, setPasswordMsgType] = useState<
"success" | "error" | null
>(null);
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
// v1.23 — passkey + second-factor management moved to the dedicated
// /settings/security hub so "how I secure my account" reads as one place.
@@ -298,52 +284,6 @@ export function AccountSection() {
setSaving(false);
}
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
setPasswordSaving(true);
setPasswordMsg(null);
setPasswordMsgType(null);
if (newPassword !== confirmPassword) {
setPasswordMsg({ key: "settings.passwordMismatch" });
setPasswordMsgType("error");
setPasswordSaving(false);
return;
}
try {
const res = await apiFetchRaw("/api/auth/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
currentPassword,
newPassword,
confirmPassword,
}),
});
const json = await res.json();
if (!res.ok) {
setPasswordMsg(
json.error ? { text: json.error } : { key: "settings.savingError" },
);
setPasswordMsgType("error");
return;
}
setPasswordMsg({ key: "settings.passwordUpdated" });
setPasswordMsgType("success");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} catch {
setPasswordMsg({ key: "common.networkError" });
setPasswordMsgType("error");
} finally {
setPasswordSaving(false);
}
}
if (!mounted || isLoading) {
return (
<div className="flex h-64 items-center justify-center">
@@ -584,32 +524,12 @@ export function AccountSection() {
provider-configuration screen. */}
<AboutMeSection isAuthenticated={isAuthenticated} />
{/* Cycle-tracking enable on-ramp — auto-on for female accounts, but this
lets any account opt in (or opt out) before the gated /cycle page is
reachable. */}
<CycleTrackingCard isAuthenticated={isAuthenticated} />
{/* Cycle tracking is a module, and the Modules hub carries its switch.
The second enable surface that used to sit here asked the same
question in a second place. */}
{/* Password card. The action is this card's only one, so it sits in
the card's action row like every other primary. It stays full-width
under `sm` — the German "Passwort ändern" is long enough that a
right-hugging button used to break through the card's right border
on a narrow phone. */}
<SettingsCard>
<SettingsCardHeader
icon={Shield}
title={t("settings.passwordReset")}
description={t("settings.changePasswordDescription")}
/>
<SettingsCardActions>
<Button
type="button"
onClick={() => setPasswordDialogOpen(true)}
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
>
{t("settings.changePassword")}
</Button>
</SettingsCardActions>
</SettingsCard>
{/* Changing the password is a security control, so the card lives in
Settings → Security beside the second factors and the passkeys. */}
{/* v1.25.7 — active-session management + the security-activity feed
live only under Settings → Data & Privacy now; the duplicate cards
@@ -619,85 +539,6 @@ export function AccountSection() {
Erweitert. It is a maintenance / reset action, not a profile or
security control, so it sits beside Research Mode + the danger zone. */}
<ResponsiveSheet
open={passwordDialogOpen}
onOpenChange={(open) => {
setPasswordDialogOpen(open);
if (!open) {
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setPasswordMsg(null);
setPasswordMsgType(null);
}
}}
title={t("settings.passwordReset")}
description={t("settings.changePasswordDescription")}
className="sm:max-w-xl"
>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="grid gap-3 lg:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="current-password">
{t("settings.currentPassword")}
</Label>
<PasswordInput
id="current-password"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">{t("settings.newPassword")}</Label>
<PasswordInput
id="new-password"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">
{t("settings.confirmNewPassword")}
</Label>
<PasswordInput
id="confirm-password"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
</div>
{passwordMsg && (
<p
role="alert"
className={`text-sm ${
passwordMsgType === "success"
? "text-success"
: "text-destructive"
}`}
>
{statusText(passwordMsg, t)}
</p>
)}
<Button
type="submit"
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={passwordSaving}
>
{passwordSaving ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="h-4 w-4" />
)}
{t("settings.changePassword")}
</Button>
</form>
</ResponsiveSheet>
<SeededFormDiscardDialog
open={profileDismissal.discardDialogOpen}
onConfirm={profileDismissal.confirmDiscard}
+10 -170
View File
@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, BookOpenCheck, Loader2, Trash2 } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2, Trash2 } from "lucide-react";
import {
AlertDialog,
@@ -16,19 +16,10 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { SettingsInfoTile } from "@/components/settings/_info-tile";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Switch } from "@/components/ui/switch";
import { ResearchModeAcknowledgmentDialog } from "@/components/medications/research-mode-acknowledgment-dialog";
import { formatDateTime } from "@/lib/format";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
type ResearchModeStatus,
researchModeGateState,
} from "@/lib/medications/research-mode-types";
import { apiFetchRaw } from "@/lib/api/api-fetch";
/**
@@ -40,179 +31,28 @@ import { apiFetchRaw } from "@/lib/api/api-fetch";
* data" surface that should never live next to a single-click export
* button.
*
* v1.4.25 W19c-Frontend adds the Research Mode toggle on this page.
* Research Mode and the data-reset are both opt-in / version-gated
* controls; they share a semantic shelf even though they touch
* different parts of the user record. Toggle ON opens the
* acknowledgment dialog (the user reads + confirms before the server
* stamps the version). Toggle OFF fires DELETE directly. A separate
* amber banner above the toggle catches the version-mismatch case
* (server bumped the disclaimer while the user was still
* acknowledged) and surfaces a Re-acknowledge CTA that re-opens the
* dialog.
* v1.4.25 added a Research Mode opt-in here, gating the estimated GLP-1
* drug-level curve behind an acknowledged disclaimer version. The chart
* stopped reading the flag some releases ago and has painted for everyone
* since, so the toggle governed nothing: flipping it off changed no screen.
* The curve is simply part of the medication page now, and the switch, its
* acknowledgment dialog, the endpoint behind it and the three user columns
* it stamped are gone. This page is the destructive controls.
*/
export function AdvancedSection() {
// v1.18.6 (W9) — the visible heading + subtitle now come from the shared
// `<SettingsSectionFrame>` in the route; this body is the advanced cards.
// v1.18.6.1 — the guided tour replay card was removed: the tour now exists
// only as the first-time auto-start after onboarding. This page is the
// research-mode + data/account destructive controls.
// data/account destructive controls.
return (
<div className="space-y-6">
<ResearchModeCard />
<DataResetCard />
<AccountDeleteCard />
</div>
);
}
/**
* v1.4.25 W21 Fix-N (simp-H3) — pull the nested ternary that decided
* the toggle's caption out of the JSX. The four-way ladder (loading /
* enabled-open / enabled-stale / disabled) is dense to read inline,
* and the surface stays open to a fifth state ("never queried") when
* we wire the offline shell in v1.4.26.
*/
function researchModeStatusLabel(
status: ResearchModeStatus | null | undefined,
isLoading: boolean,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
if (isLoading) return t("common.loading");
const gate = researchModeGateState(status);
if (gate === "off") return t("settings.researchMode.disabledStatus");
if (gate === "stale") return t("settings.researchMode.enabledStaleStatus");
return t("settings.researchMode.acknowledgedOn", {
date: status?.acknowledgedAt ? formatDateTime(status.acknowledgedAt) : "—",
});
}
function ResearchModeCard() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
const [toggleBusy, setToggleBusy] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { data: status, isLoading } = useQuery<ResearchModeStatus | null>({
queryKey: queryKeys.researchMode(),
queryFn: async () => {
const res = await apiFetchRaw("/api/auth/me/research-mode");
if (!res.ok) return null;
const json = await res.json();
return json.data as ResearchModeStatus;
},
staleTime: 60 * 1000,
});
const gateState = researchModeGateState(status);
const showRePrompt = gateState === "stale";
// The Switch's `checked` mirrors the server flag exactly — even when
// the version is stale we keep the toggle "on" so the user sees that
// their previous choice is preserved; the banner above the toggle
// explains why the chart isn't painting.
const switchChecked = !!status?.enabled;
async function handleToggle(next: boolean) {
if (toggleBusy) return;
setErrorMessage(null);
if (next) {
// Toggle ON → open the acknowledgment dialog. The dialog owns
// the POST; on success it invalidates the `research-mode` query
// and the toggle reflects the new state.
setDialogOpen(true);
return;
}
// Toggle OFF → fire DELETE directly.
setToggleBusy(true);
try {
const res = await apiFetchRaw("/api/auth/me/research-mode", {
method: "DELETE",
});
if (!res.ok) {
setErrorMessage(t("settings.researchMode.disableError"));
return;
}
queryClient.invalidateQueries({ queryKey: queryKeys.researchMode() });
} catch {
setErrorMessage(t("settings.researchMode.disableError"));
} finally {
setToggleBusy(false);
}
}
return (
<SettingsCard data-slot="settings-research-mode-card">
<SettingsCardHeader
icon={BookOpenCheck}
title={t("settings.researchMode.sectionTitle")}
description={t("settings.researchMode.subtitle")}
/>
<p className="text-sm">{t("settings.researchMode.subtitleDetail")}</p>
{showRePrompt && (
<SettingsInfoTile
icon={AlertTriangle}
tone="warning"
role="alert"
data-slot="settings-research-mode-reprompt"
title={t("settings.researchMode.rePromptTitle")}
>
<p>{t("settings.researchMode.rePromptBody")}</p>
<Button
size="sm"
variant="outline"
className="mt-2 min-h-11 sm:min-h-9"
onClick={() => setDialogOpen(true)}
data-slot="settings-research-mode-reprompt-cta"
>
{t("settings.researchMode.rePromptCta")}
</Button>
</SettingsInfoTile>
)}
<div className="flex items-start justify-between gap-3">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("settings.researchMode.toggleLabel")}
</p>
<p
className="text-muted-foreground text-xs"
data-slot="settings-research-mode-status"
>
{researchModeStatusLabel(status, isLoading, t)}
</p>
</div>
<Switch
checked={switchChecked}
disabled={isLoading || toggleBusy}
onCheckedChange={(next) => {
void handleToggle(next);
}}
aria-label={t("settings.researchMode.toggleLabel")}
data-slot="settings-research-mode-toggle"
/>
</div>
{errorMessage && (
<p
role="alert"
className="text-destructive text-sm"
data-slot="settings-research-mode-error"
>
{errorMessage}
</p>
)}
<ResearchModeAcknowledgmentDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
currentDisclaimerVersion={status?.currentDisclaimerVersion ?? null}
/>
</SettingsCard>
);
}
function DataResetCard() {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -1,102 +0,0 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { CalendarHeart } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiPatch } from "@/lib/api/api-fetch";
/**
* v1.15.0 — the cycle-tracking enable on-ramp.
*
* The cycle vertical auto-enables for accounts set to female (gender-derived
* default), but everyone else needs a way IN: the gated `/cycle` page bounces
* them home and the in-page settings (where the goal/prefs live) sit behind
* that same gate. This card reads the UNGATED `/api/auth/me/cycle-prefs` so any
* account can flip `cycleTrackingEnabled` on (or a female account can opt out)
* before the gated page is reachable. PATCHes the same prefs route the iOS app
* uses; invalidates the nav gate so the sidebar/bottom-nav entry appears.
*/
interface CyclePrefsShape {
cycleTrackingEnabled: boolean;
}
export function CycleTrackingCard({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { data } = useQuery({
queryKey: queryKeys.cyclePrefs(),
enabled: isAuthenticated,
queryFn: async () => {
return apiGet<CyclePrefsShape>("/api/auth/me/cycle-prefs");
},
});
const toggle = useMutation({
mutationFn: async (enabled: boolean) => {
return apiPatch<CyclePrefsShape>("/api/auth/me/cycle-prefs", { enabled });
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.cyclePrefs() });
void queryClient.invalidateQueries({ queryKey: queryKeys.cycle() });
// The nav gate reads the resolved enable state off /auth/me.
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
},
});
const enabled = data?.cycleTrackingEnabled ?? false;
return (
<SettingsCard>
<SettingsCardHeader
icon={CalendarHeart}
title={t("settings.cycleTracking.title")}
description={t("settings.cycleTracking.description")}
/>
<p className="text-sm">{t("settings.cycleTracking.detail")}</p>
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<Label htmlFor="cycle-tracking-enable" className="text-sm">
{t("settings.cycleTracking.enable")}
</Label>
<Switch
id="cycle-tracking-enable"
checked={enabled}
disabled={toggle.isPending || data == null}
onCheckedChange={(v) => toggle.mutate(v)}
className="mt-0.5 shrink-0"
/>
</div>
{enabled ? (
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
asChild
>
<Link href="/cycle">{t("settings.cycleTracking.manage")}</Link>
</Button>
) : null}
{toggle.isError ? (
<p className="text-destructive text-sm" role="alert">
{t("settings.cycleTracking.error")}
</p>
) : null}
</div>
</SettingsCard>
);
}
@@ -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 }) {
+1 -1
View File
@@ -39,7 +39,7 @@ export function LayoutSection() {
);
return (
<SettingsCard className="divide-border divide-y overflow-hidden p-0 md:p-0">
<SettingsCard flush className="divide-border divide-y">
{visibleGroups.map((group) => (
<Link
key={group.id}
+192
View File
@@ -0,0 +1,192 @@
"use client";
import { useState } from "react";
import { Loader2, Save, Shield } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
statusText,
type StatusMessage,
} from "@/components/settings/account-section/account-section-utils";
import { useTranslations } from "@/lib/i18n/context";
import { apiFetchRaw } from "@/lib/api/api-fetch";
/**
* Change-password card.
*
* It used to sit at the bottom of Settings → Account, under the profile form
* and the identity fields. Changing a password is not a profile edit — it is
* the same decision as enrolling a second factor or adding a passkey, and
* those live in Settings → Security. The card moved to sit with them so
* "how I get into this account" reads as one place.
*
* The status hint stores the i18n KEY, not the translated string, so a locale
* switch re-renders it in the new language — the settings-wide convention
* `statusText` exists for.
*/
export function PasswordCard() {
const { t } = useTranslations();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<StatusMessage | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
async function handleChangePassword(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
setMsg(null);
setMsgType(null);
if (newPassword !== confirmPassword) {
setMsg({ key: "settings.passwordMismatch" });
setMsgType("error");
setSaving(false);
return;
}
try {
const res = await apiFetchRaw("/api/auth/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
currentPassword,
newPassword,
confirmPassword,
}),
});
const json = await res.json();
if (!res.ok) {
setMsg(
json.error ? { text: json.error } : { key: "settings.savingError" },
);
setMsgType("error");
return;
}
setMsg({ key: "settings.passwordUpdated" });
setMsgType("success");
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} catch {
setMsg({ key: "common.networkError" });
setMsgType("error");
} finally {
setSaving(false);
}
}
return (
<>
{/* The action is this card's only one, so it sits in the card's action
row like every other primary. It stays full-width under `sm` — the
German "Passwort ändern" is long enough that a right-hugging button
used to break through the card's right border on a narrow phone. */}
<SettingsCard data-slot="settings-password-card">
<SettingsCardHeader
icon={Shield}
title={t("settings.passwordReset")}
description={t("settings.changePasswordDescription")}
/>
<SettingsCardActions>
<Button
type="button"
onClick={() => setDialogOpen(true)}
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
>
{t("settings.changePassword")}
</Button>
</SettingsCardActions>
</SettingsCard>
<ResponsiveSheet
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) {
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setMsg(null);
setMsgType(null);
}
}}
title={t("settings.passwordReset")}
description={t("settings.changePasswordDescription")}
className="sm:max-w-xl"
>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="grid gap-3 lg:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="current-password">
{t("settings.currentPassword")}
</Label>
<PasswordInput
id="current-password"
autoComplete="current-password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="new-password">{t("settings.newPassword")}</Label>
<PasswordInput
id="new-password"
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">
{t("settings.confirmNewPassword")}
</Label>
<PasswordInput
id="confirm-password"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
</div>
{msg && (
<p
role="alert"
className={`text-sm ${
msgType === "success" ? "text-success" : "text-destructive"
}`}
>
{statusText(msg, t)}
</p>
)}
<Button
type="submit"
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={saving}
>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Save className="h-4 w-4" />
)}
{t("settings.changePassword")}
</Button>
</form>
</ResponsiveSheet>
</>
);
}
+3 -6
View File
@@ -179,7 +179,9 @@ export function PrivacySection() {
</SettingsCardActions>
</SettingsCard>
{/* Privacy posture (AI privacy mode + research mode live on their pages) */}
{/* Privacy posture. The AI privacy mode is what is left to decide here:
research mode is gone, so the second button pointed at a page that
no longer carries the control it named. */}
<SettingsCard>
<SettingsCardHeader
icon={Sparkles}
@@ -192,11 +194,6 @@ export function PrivacySection() {
{t("settings.privacy.posture.openAi")}
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/settings/advanced">
{t("settings.privacy.posture.openResearch")}
</Link>
</Button>
</SettingsCardActions>
</SettingsCard>
@@ -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">
@@ -6,6 +6,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { PasswordCard } from "@/components/settings/password-card";
import { TotpCard } from "./totp-card";
import { SecurityKeysCard, type WebauthnKeyInfo } from "./security-keys-card";
import { PasskeyListSection } from "./passkey-list-section";
@@ -45,28 +46,35 @@ export function SecuritySection() {
passkeys != null &&
passkeys.length === 0;
if (isLoading || !status) {
return (
<div className="space-y-4">
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
</div>
);
}
return (
<div className="space-y-6">
{showNudge && <PasskeyUpgradeNudge />}
<TotpCard
enabled={status.totp.enabled}
recoveryCodesRemaining={status.recoveryCodesRemaining}
/>
{/* The password leads, and it renders before the factor status has
landed — it depends on nothing this page fetches. It is also the
only way to change a password now that the Account card is gone, so
a `/api/auth/me/mfa` that never answers must not take it down with
the cards that do need it. */}
<PasswordCard />
<SecurityKeysCard keys={status.webauthn} />
{isLoading || !status ? (
<>
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-40 w-full rounded-xl" />
</>
) : (
<>
<TotpCard
enabled={status.totp.enabled}
recoveryCodesRemaining={status.recoveryCodesRemaining}
/>
<PasskeyListSection isAuthenticated={isAuthenticated} />
<SecurityKeysCard keys={status.webauthn} />
<PasskeyListSection isAuthenticated={isAuthenticated} />
</>
)}
</div>
);
}
+22 -3
View File
@@ -28,10 +28,22 @@ import { cn } from "@/lib/utils";
*
* `as="section"` keeps a card's semantic landmark element (a `<section>` with
* an `aria-labelledby`) while painting the same shape, rhythm, and padding.
*
* `flush` is for the other body shape: an edge-to-edge `divide-y` ledger whose
* rows carry their own inset. Such a card drops BOTH the padding and the gap.
* Dropping only the padding is the trap — the gap survives as dead,
* unclickable space between the rows that the first row does not have, so the
* list reads a step off itself. `<InsightSectionCard flush>` names the same
* shape on the Insights side.
*/
const SETTINGS_CARD_SHELL = "flex flex-col gap-4 p-4 md:p-6";
// Both breakpoints are named explicitly: the `<Card>` primitive carries
// `md:gap-6` / `md:py-6`, and a bare `gap-0` / `p-0` loses to a `md:` variant
// from `md` up — which is exactly the width the dead space showed at.
const SETTINGS_CARD_FLUSH_SHELL =
"flex flex-col gap-0 overflow-hidden p-0 md:gap-0 md:p-0";
type SettingsCardOwnProps = { className?: string };
type SettingsCardOwnProps = { className?: string; flush?: boolean };
type SettingsCardProps<E extends React.ElementType> = SettingsCardOwnProps & {
as?: E;
@@ -40,6 +52,7 @@ type SettingsCardProps<E extends React.ElementType> = SettingsCardOwnProps & {
export function SettingsCard<E extends React.ElementType = typeof Card>({
as,
className,
flush = false,
...props
}: SettingsCardProps<E>) {
if (as) {
@@ -50,7 +63,10 @@ export function SettingsCard<E extends React.ElementType = typeof Card>({
// Mirror the ui Card shell so a semantic landmark card paints
// identically to the default <Card>-backed one.
className={cn(
"bg-card text-card-foreground flex flex-col gap-4 rounded-xl border p-4 shadow-sm md:gap-6 md:p-6",
"bg-card text-card-foreground rounded-xl border shadow-sm",
flush
? SETTINGS_CARD_FLUSH_SHELL
: "flex flex-col gap-4 p-4 md:gap-6 md:p-6",
className,
)}
{...props}
@@ -61,7 +77,10 @@ export function SettingsCard<E extends React.ElementType = typeof Card>({
<Card
// Reset the primitive's flex/gap layout and apply the shared
// `p-4 md:p-6` padding; Settings bodies own their internal spacing.
className={cn(SETTINGS_CARD_SHELL, className)}
className={cn(
flush ? SETTINGS_CARD_FLUSH_SHELL : SETTINGS_CARD_SHELL,
className,
)}
{...(props as React.ComponentProps<typeof Card>)}
/>
);