mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
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:
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)),
|
||||
});
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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't");
|
||||
expect(html).toContain("Why it'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}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>)}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user