mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
feat(export): one grouped scope picker, on both surfaces that choose a scope
The export panel rendered twenty flat switches behind a collapsed disclosure, and the share-link form rendered none at all — it posted whatever the create schema defaulted to. Both now mount the same picker: twelve named groups, a count on each, the leaf grid one chevron away, and a fenced tier below a separator. The fence is mechanical, not a label. The sensitive tier has no group checkbox, so no single control anywhere in the picker can turn on more than one of mood, cycle, family history, PHQ-9, GAD-7, WHO-5 or SCI at a time. There is no select-all. The shipped template contains none of them. And the scope line under the button restates every sensitive inclusion by name rather than folding it into a count, because a number cannot be checked at a glance and "incl. Mood, PHQ-9" can. First run opens expanded with the named standard template applied and the fenced tier visibly empty, so the user presses Generate having seen twelve named groups. Every later run opens collapsed carrying their own last selection, which makes the waiting-room path two interactions. Only leaves are ever stored. A group's checkbox is derived from its leaves, so the two cannot disagree, and switching a group off and back on inside one session restores the pattern instead of flooding it. The cycle export card is deleted. It posted empty groups under a docblock claiming every other section was off, and empty groups resolved to the defaults — so a "cycle only" download carried weight, blood pressure, pulse, oxygen saturation, body fat, body composition, the medication list, adherence, labs, allergies and family history. Cycle is a fenced leaf in the one picker now, reachable for the PDF as well as for FHIR, which is more than the card offered. The panel gains a 30-day preset and a custom window, and its error line says what went wrong instead of interpolating a status code. The Checkbox primitive learns the indeterminate state it already had the Radix support for. A partly-selected group drawn as unchecked would say "none of this is included", which on this surface is a false statement about what leaves the instance.
This commit is contained in:
1 parent
5e490f31ec
commit
582db8a538
21 files changed
+1409
-1015
No files matched your search
@@ -110,6 +110,11 @@ export const GET = apiHandler(async () => {
|
||||
// formatters and the <DateField> primitive.
|
||||
dateFormat: user.dateFormat ?? "AUTO",
|
||||
lastReportPracticeName: user.lastReportPracticeName ?? null,
|
||||
// The owner's saved report selection — the leaf inclusion list plus the
|
||||
// format / range / charts choices the export panel restores. `null` when
|
||||
// the account has never saved one, which is what makes the panel show the
|
||||
// named template on the first run rather than a silent server default.
|
||||
reportSelection: user.reportSelectionJson ?? null,
|
||||
// v1.4.47 W3 — per-user Coach opt-out. Default `false` if the
|
||||
// column is absent (partial-deploy rollback safety, see migration
|
||||
// 0078 commentary). Every Coach mount point on the client checks
|
||||
|
||||
@@ -22,9 +22,7 @@
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CalendarHeart,
|
||||
Download,
|
||||
FileJson,
|
||||
FileSpreadsheet,
|
||||
@@ -44,9 +42,8 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ImportPanel } from "@/components/settings/import-panel";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
|
||||
type ExportFormat = "CSV" | "JSON" | "FHIR";
|
||||
|
||||
@@ -96,11 +93,6 @@ export function ExportSection() {
|
||||
<MedicationsCsvCard />
|
||||
<MoodCsvCard />
|
||||
<FullBackupCard />
|
||||
{/* R30 — cycle export, gated on cycle tracking being enabled.
|
||||
The card mounts only for accounts with cycle data; it reuses
|
||||
the health-record FHIR export with the reproductive section
|
||||
opted in, so there is no separate backend path. */}
|
||||
<CycleExportCard />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -556,111 +548,3 @@ function FullBackupCard() {
|
||||
}
|
||||
|
||||
// ─────────────────────────── Cycle export (gated) ───────────────────────────
|
||||
|
||||
/**
|
||||
* R30 — cycle / reproductive-health export, GATED on cycle tracking
|
||||
* being enabled for the account. A standalone cycle export was removed
|
||||
* from the cycle settings in v1.15.4 (it lives in the full backup); this
|
||||
* surfaces an explicit, opt-in cycle export here for accounts that track
|
||||
* a cycle. It reuses the flagship `/api/export/health-record` route with
|
||||
* the reproductive section opted in and every other section off — no
|
||||
* separate backend path. The card does not render at all when cycle
|
||||
* tracking is off, so a non-cycle account never sees it.
|
||||
*/
|
||||
function CycleExportCard() {
|
||||
const { t } = useTranslations();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// The ungated prefs read tells us whether cycle tracking is enabled
|
||||
// without tripping the cycle gate's 403.
|
||||
const prefsQuery = useQuery({
|
||||
queryKey: queryKeys.cyclePrefs(),
|
||||
queryFn: async (): Promise<{ cycleTrackingEnabled: boolean }> => {
|
||||
return apiGet<{ cycleTrackingEnabled: boolean }>(
|
||||
"/api/auth/me/cycle-prefs",
|
||||
{ credentials: "include" },
|
||||
);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Gate: render nothing until we know cycle is on. A failed/absent read
|
||||
// keeps the card hidden (fail-closed) so it never appears spuriously.
|
||||
if (prefsQuery.data?.cycleTrackingEnabled !== true) return null;
|
||||
|
||||
async function handleGenerate() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/export/health-record", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
format: "fhir",
|
||||
sections: {
|
||||
vitals: {},
|
||||
cardioFitness: {},
|
||||
activity: {},
|
||||
glucose: false,
|
||||
medications: {},
|
||||
mood: false,
|
||||
bmi: false,
|
||||
cycle: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError(t("settings.sections.export.downloadFailed"));
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `healthlog-cycle-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
setError(t("settings.sections.export.downloadFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ExportCardShell
|
||||
testId="export-card-cycle"
|
||||
icon={CalendarHeart}
|
||||
title={t("settings.sections.export.cards.cycle.title")}
|
||||
description={t("settings.sections.export.cards.cycle.description")}
|
||||
format="FHIR"
|
||||
footer={
|
||||
<Button
|
||||
data-testid="export-action-cycle"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={handleGenerate}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.sections.export.actions.download")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</ExportCardShell>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ import { Share2 } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { HealthRecordExportPanel } from "@/components/settings/health-record-export-panel";
|
||||
import { HealthRecordExportPanel } from "@/components/settings/report-selection/selection-panel";
|
||||
import { SharingSection } from "@/components/settings/sharing-section";
|
||||
|
||||
export function GesundheitsakteSection() {
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* The data-selection controls of the health-record export panel: the state
|
||||
* shape, its starting values, and the wire selection they build.
|
||||
*
|
||||
* Split out of the panel component so the selection contract is testable and
|
||||
* extendable without the client component around it. The panel owns layout and
|
||||
* interaction; this module owns "which controls exist and what each one sends",
|
||||
* which is the part the export route, the aggregator and the control-gating
|
||||
* guard all have to agree with — and the part the grouped-selection work will
|
||||
* replace with a grouped registry, one file, without touching the rendering.
|
||||
*/
|
||||
/**
|
||||
* Every data-selection control this panel renders, as one flat state shape.
|
||||
*
|
||||
* The shape is load-bearing beyond the component: the control-gating
|
||||
* guard (`src/__tests__/doctor-report-control-gating-guard.test.ts`) walks these
|
||||
* keys, folds each one through {@link buildSelectionSections} and
|
||||
* `toDoctorReportPrefs`, and fails the build unless flipping it changes the
|
||||
* resolved selection the aggregator consumes. Adding a control here without a
|
||||
* gating path behind it is a build error, which is how eight controls that
|
||||
* changed nothing stopped being possible.
|
||||
*/
|
||||
export interface SectionState {
|
||||
weight: boolean;
|
||||
bp: boolean;
|
||||
pulse: boolean;
|
||||
oxygenSaturation: boolean;
|
||||
bodyFat: boolean;
|
||||
bodyComposition: boolean;
|
||||
restingHeartRate: boolean;
|
||||
hrv: boolean;
|
||||
vo2max: boolean;
|
||||
steps: boolean;
|
||||
distance: boolean;
|
||||
sleep: boolean;
|
||||
glucose: boolean;
|
||||
medList: boolean;
|
||||
compliance: boolean;
|
||||
mood: boolean;
|
||||
bmi: boolean;
|
||||
labs: boolean;
|
||||
allergies: boolean;
|
||||
familyHistory: boolean;
|
||||
mentalHealthScreeners: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_SECTIONS: SectionState = {
|
||||
weight: true,
|
||||
bp: true,
|
||||
pulse: true,
|
||||
oxygenSaturation: true,
|
||||
bodyFat: true,
|
||||
bodyComposition: false,
|
||||
restingHeartRate: false,
|
||||
hrv: false,
|
||||
vo2max: false,
|
||||
steps: false,
|
||||
distance: false,
|
||||
sleep: false,
|
||||
glucose: true,
|
||||
medList: true,
|
||||
compliance: true,
|
||||
mood: false, // privacy default
|
||||
bmi: true,
|
||||
labs: true,
|
||||
allergies: true,
|
||||
familyHistory: true,
|
||||
// Depression / anxiety / wellbeing / insomnia screening totals — opt-in,
|
||||
// same stance as mood.
|
||||
mentalHealthScreeners: false,
|
||||
};
|
||||
|
||||
export function buildSelectionSections(s: SectionState) {
|
||||
return {
|
||||
vitals: {
|
||||
weight: s.weight,
|
||||
bp: s.bp,
|
||||
pulse: s.pulse,
|
||||
oxygenSaturation: s.oxygenSaturation,
|
||||
bodyFat: s.bodyFat,
|
||||
bodyComposition: s.bodyComposition,
|
||||
},
|
||||
cardioFitness: {
|
||||
restingHeartRate: s.restingHeartRate,
|
||||
hrv: s.hrv,
|
||||
vo2max: s.vo2max,
|
||||
},
|
||||
activity: {
|
||||
steps: s.steps,
|
||||
distance: s.distance,
|
||||
sleep: s.sleep,
|
||||
},
|
||||
glucose: s.glucose,
|
||||
medications: { list: s.medList, compliance: s.compliance },
|
||||
mood: s.mood,
|
||||
bmi: s.bmi,
|
||||
labs: s.labs,
|
||||
allergies: s.allergies,
|
||||
familyHistory: s.familyHistory,
|
||||
mentalHealthScreeners: s.mentalHealthScreeners,
|
||||
};
|
||||
}
|
||||
@@ -1,477 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.7.0 — Settings → Export → Health Record export panel.
|
||||
*
|
||||
* One surface that drives `POST /api/export/health-record`: a format
|
||||
* toggle (PDF / FHIR / package) that drives the visible options below it
|
||||
* (per the "dropdown drives the form, no top/bottom split" preference),
|
||||
* a date-range picker, grouped data-section toggles, and the generate
|
||||
* action that streams the artefact as a download.
|
||||
*
|
||||
* Mood stays default-OFF everywhere (privacy). PDF-only fields (practice
|
||||
* name, charts) hide when the format is FHIR. The ePA-compat note shows
|
||||
* for the FHIR + package formats.
|
||||
*
|
||||
* v1.18.1 (D9) — the panel renders as a normal settings card (the v1.12
|
||||
* gradient/glow hero treatment was dropped: the gradient read too crass next
|
||||
* to the rest of the settings surface). The grouped data-section toggles live
|
||||
* behind a collapsible disclosure, collapsed by default, so the panel opens
|
||||
* compact instead of as a long always-expanded checklist.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { useId, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ChevronDown,
|
||||
Download,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DEFAULT_SECTIONS,
|
||||
buildSelectionSections,
|
||||
type SectionState,
|
||||
} from "@/components/settings/health-record-export-controls";
|
||||
|
||||
type ExportFormat = "pdf" | "fhir" | "package";
|
||||
|
||||
const EXPORT_FORMATS: readonly ExportFormat[] = ["pdf", "fhir", "package"];
|
||||
|
||||
export function HealthRecordExportPanel() {
|
||||
const { t, locale } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [format, setFormat] = useState<ExportFormat>("pdf");
|
||||
const [days, setDays] = useState<number>(90);
|
||||
const [practiceName, setPracticeName] = useState("");
|
||||
const [practiceNameSeededUserId, setPracticeNameSeededUserId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [includeCharts, setIncludeCharts] = useState(true);
|
||||
const [includeAiSummary, setIncludeAiSummary] = useState(false);
|
||||
const [sections, setSections] = useState<SectionState>(DEFAULT_SECTIONS);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// The included-data checklist is a disclosure, collapsed by default —
|
||||
// the panel opens compact instead of as a long always-expanded list.
|
||||
const [includedDataOpen, setIncludedDataOpen] = useState(false);
|
||||
const includedDataPanelId = useId();
|
||||
|
||||
// Seed the practice name from the last export once the auth payload lands.
|
||||
// `useAuth` resolves asynchronously, so a `useState` initializer would read
|
||||
// `undefined` and stick at empty. Store-the-id-alongside-the-state (the same
|
||||
// pattern the account form uses) syncs during render instead of in an
|
||||
// effect, and the per-user gate means a late re-resolve never clobbers a
|
||||
// field the user is already typing into.
|
||||
if (user && user.id !== practiceNameSeededUserId) {
|
||||
setPracticeNameSeededUserId(user.id);
|
||||
setPracticeName(user.lastReportPracticeName ?? "");
|
||||
}
|
||||
|
||||
const isPdfLike = format === "pdf" || format === "package";
|
||||
const isFhirLike = format === "fhir" || format === "package";
|
||||
|
||||
const { getRadioProps: getFormatRadioProps } = useRovingRadioGroup({
|
||||
count: EXPORT_FORMATS.length,
|
||||
selectedIndex: EXPORT_FORMATS.indexOf(format),
|
||||
onSelect: (index) => setFormat(EXPORT_FORMATS[index]!),
|
||||
});
|
||||
|
||||
function toggle(key: keyof SectionState) {
|
||||
setSections((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}
|
||||
|
||||
// The single "Medikamente" control gates the whole medication block
|
||||
// (list + compliance) so unchecking it actually excludes all medication
|
||||
// data from the export — matching the iOS five-section model. Until
|
||||
// that was true of the compliance figures only until this change: the list
|
||||
// names, doses and schedules, and the per-dose ledger behind it, went out
|
||||
// regardless of the switch.
|
||||
function toggleMedications() {
|
||||
setSections((prev) => {
|
||||
const next = !(prev.medList && prev.compliance);
|
||||
return { ...prev, medList: next, compliance: next };
|
||||
});
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/export/health-record", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
format,
|
||||
// Carry the active in-app locale so the generated artefact matches
|
||||
// the UI language instead of falling back to the browser's
|
||||
// Accept-Language header on the server.
|
||||
locale,
|
||||
range: { days },
|
||||
practiceName: practiceName.trim() || undefined,
|
||||
includeCharts,
|
||||
includeAiSummary,
|
||||
sections: buildSelectionSections(sections),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError(`${res.status}`);
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const ext = format === "pdf" ? "pdf" : format === "fhir" ? "json" : "zip";
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `healthlog-health-record-${new Date()
|
||||
.toISOString()
|
||||
.slice(0, 10)}.${ext}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
// The route just persisted the practice name, so the cached `/me`
|
||||
// payload is stale. Refresh it, or a remount of this panel would seed
|
||||
// from the previous value.
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
as="section"
|
||||
aria-labelledby="health-record-export-title"
|
||||
data-testid="health-record-export-panel"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
className="mb-4"
|
||||
icon={FileText}
|
||||
titleId="health-record-export-title"
|
||||
title={t("settings.healthRecord.title")}
|
||||
description={t("settings.healthRecord.description")}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Format + range share a row on desktop so the panel opens
|
||||
denser; on mobile they stack. */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<fieldset className="space-y-1.5">
|
||||
<legend className="mb-1 text-sm font-medium">
|
||||
{t("settings.healthRecord.format")}
|
||||
</legend>
|
||||
<div className="flex flex-wrap gap-2" role="radiogroup">
|
||||
{EXPORT_FORMATS.map((f, index) => (
|
||||
<Button
|
||||
key={f}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={format === f}
|
||||
variant={format === f ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setFormat(f)}
|
||||
{...getFormatRadioProps(index)}
|
||||
>
|
||||
{t(`settings.healthRecord.format_${f}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Date range */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hr-range">{t("settings.healthRecord.range")}</Label>
|
||||
<NativeSelect
|
||||
id="hr-range"
|
||||
value={String(days)}
|
||||
onChange={(e) => setDays(Number(e.target.value))}
|
||||
>
|
||||
<option value="90">{t("settings.healthRecord.range90")}</option>
|
||||
<option value="180">{t("settings.healthRecord.range180")}</option>
|
||||
<option value="365">{t("settings.healthRecord.range365")}</option>
|
||||
</NativeSelect>
|
||||
{/* Entry point into the document vault for the report period
|
||||
(navigation only — attaching documents to the PDF is a later
|
||||
phase). Year granularity is what the vault's URL filters
|
||||
offer; the current year covers the bulk of any range. Only
|
||||
rendered when the documents module is enabled. */}
|
||||
{user?.modules?.inboundDocuments ? (
|
||||
<Link
|
||||
href={`/documents?year=${new Date().getFullYear()}`}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-md text-xs transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<FolderOpen className="size-3.5" aria-hidden />
|
||||
{t("settings.healthRecord.documentsLink")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Practice name — PDF only */}
|
||||
{isPdfLike && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hr-practice">
|
||||
{t("settings.healthRecord.practiceName")}
|
||||
</Label>
|
||||
<Input
|
||||
id="hr-practice"
|
||||
value={practiceName}
|
||||
onChange={(e) => setPracticeName(e.target.value)}
|
||||
maxLength={120}
|
||||
placeholder={t("settings.healthRecord.practiceNamePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section groups — collapsible, collapsed by default so the
|
||||
panel opens compact. The disclosure follows the inline
|
||||
aria-expanded/aria-controls pattern used elsewhere in the app
|
||||
(e.g. the insights recommendation cards). */}
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="sr-only">
|
||||
{t("settings.healthRecord.includedData")}
|
||||
</legend>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="health-record-included-data-toggle"
|
||||
aria-expanded={includedDataOpen}
|
||||
aria-controls={includedDataPanelId}
|
||||
onClick={() => setIncludedDataOpen((v) => !v)}
|
||||
className="text-foreground hover:bg-muted/40 focus-visible:ring-ring/50 flex min-h-11 w-full items-center justify-between gap-3 rounded-lg px-1 py-1 text-left text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<span>{t("settings.healthRecord.includedData")}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"text-muted-foreground h-4 w-4 shrink-0 transition-transform",
|
||||
includedDataOpen && "rotate-180",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{includedDataOpen && (
|
||||
<div
|
||||
id={includedDataPanelId}
|
||||
data-testid="health-record-included-data-panel"
|
||||
className="animate-insight-in space-y-3"
|
||||
style={{ animationDuration: "200ms" }}
|
||||
>
|
||||
<div className="border-border space-y-2 rounded-lg border p-3">
|
||||
<p className="text-xs font-semibold">
|
||||
{t("settings.healthRecord.groupVitals")}
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.weight")}
|
||||
checked={sections.weight}
|
||||
onToggle={() => toggle("weight")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.bp")}
|
||||
checked={sections.bp}
|
||||
onToggle={() => toggle("bp")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.pulse")}
|
||||
checked={sections.pulse}
|
||||
onToggle={() => toggle("pulse")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.oxygenSaturation")}
|
||||
checked={sections.oxygenSaturation}
|
||||
onToggle={() => toggle("oxygenSaturation")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.bodyFat")}
|
||||
checked={sections.bodyFat}
|
||||
onToggle={() => toggle("bodyFat")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.bodyComposition")}
|
||||
checked={sections.bodyComposition}
|
||||
onToggle={() => toggle("bodyComposition")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border space-y-2 rounded-lg border p-3">
|
||||
<p className="text-xs font-semibold">
|
||||
{t("settings.healthRecord.groupCardio")}
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.restingHeartRate")}
|
||||
checked={sections.restingHeartRate}
|
||||
onToggle={() => toggle("restingHeartRate")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.hrv")}
|
||||
checked={sections.hrv}
|
||||
onToggle={() => toggle("hrv")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.vo2max")}
|
||||
checked={sections.vo2max}
|
||||
onToggle={() => toggle("vo2max")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border space-y-2 rounded-lg border p-3">
|
||||
<p className="text-xs font-semibold">
|
||||
{t("settings.healthRecord.groupActivity")}
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.steps")}
|
||||
checked={sections.steps}
|
||||
onToggle={() => toggle("steps")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.distance")}
|
||||
checked={sections.distance}
|
||||
onToggle={() => toggle("distance")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.sleep")}
|
||||
checked={sections.sleep}
|
||||
onToggle={() => toggle("sleep")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.glucose")}
|
||||
checked={sections.glucose}
|
||||
onToggle={() => toggle("glucose")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.medications")}
|
||||
checked={sections.medList && sections.compliance}
|
||||
onToggle={toggleMedications}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.bmi")}
|
||||
checked={sections.bmi}
|
||||
onToggle={() => toggle("bmi")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.mood")}
|
||||
checked={sections.mood}
|
||||
onToggle={() => toggle("mood")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.mentalHealthScreeners")}
|
||||
checked={sections.mentalHealthScreeners}
|
||||
onToggle={() => toggle("mentalHealthScreeners")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.labs")}
|
||||
checked={sections.labs}
|
||||
onToggle={() => toggle("labs")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.allergies")}
|
||||
checked={sections.allergies}
|
||||
onToggle={() => toggle("allergies")}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.familyHistory")}
|
||||
checked={sections.familyHistory}
|
||||
onToggle={() => toggle("familyHistory")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isPdfLike && (
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.includeCharts")}
|
||||
checked={includeCharts}
|
||||
onToggle={() => setIncludeCharts((v) => !v)}
|
||||
/>
|
||||
)}
|
||||
{isPdfLike && (
|
||||
<ToggleRow
|
||||
label={t("settings.healthRecord.includeAiSummary")}
|
||||
checked={includeAiSummary}
|
||||
onToggle={() => setIncludeAiSummary((v) => !v)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
{/* The FHIR note and the generate action share the footer row so
|
||||
the panel ends compact instead of stacking a note above a
|
||||
right-aligned button. */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
{isFhirLike ? (
|
||||
<p className="text-muted-foreground max-w-md text-xs">
|
||||
{t("settings.healthRecord.fhirNote")}
|
||||
</p>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={busy}
|
||||
className="ml-auto min-h-11 sm:min-h-9"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.healthRecord.generate")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t("settings.healthRecord.error", { code: error })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
checked,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center justify-between gap-3 text-sm">
|
||||
<span>{label}</span>
|
||||
<Switch checked={checked} onCheckedChange={onToggle} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* One group row in the scope picker.
|
||||
*
|
||||
* The row is a `ListRow` carrying a group checkbox, the group name, a count
|
||||
* chip, and a disclosure that opens the leaf grid. The group checkbox is one
|
||||
* control for up to sixteen leaves, which is what makes the repeat visit two
|
||||
* interactions; the chevron is the escape hatch and never sits on the fast
|
||||
* path.
|
||||
*
|
||||
* `mixed` is a derived display state and never a click target: clicking a
|
||||
* partly-selected group turns it fully on. Only leaves are ever stored.
|
||||
*/
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ListRow } from "@/components/ui/list-row";
|
||||
import { TagChip } from "@/components/ui/tag-chip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
ReportGroupId,
|
||||
ReportLeafId,
|
||||
} from "@/lib/report-selection/catalogue";
|
||||
import type { GroupCheckState } from "@/lib/report-selection/panel-state";
|
||||
|
||||
import { LeafGrid } from "./leaf-grid";
|
||||
|
||||
export function GroupRow({
|
||||
t,
|
||||
id,
|
||||
labelKey,
|
||||
leaves,
|
||||
selected,
|
||||
checkState,
|
||||
count,
|
||||
open,
|
||||
panelId,
|
||||
onToggleGroup,
|
||||
onToggleLeaf,
|
||||
onToggleOpen,
|
||||
}: {
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
id: ReportGroupId;
|
||||
labelKey: string;
|
||||
leaves: readonly ReportLeafId[];
|
||||
selected: ReadonlySet<ReportLeafId>;
|
||||
checkState: GroupCheckState;
|
||||
count: { on: number; total: number };
|
||||
open: boolean;
|
||||
panelId: string;
|
||||
onToggleGroup: () => void;
|
||||
onToggleLeaf: (leaf: ReportLeafId) => void;
|
||||
onToggleOpen: () => void;
|
||||
}) {
|
||||
const label = t(labelKey);
|
||||
return (
|
||||
<ListRow className="space-y-2" data-testid={`report-group-row-${id}`}>
|
||||
<div className="flex min-h-11 items-center gap-3 sm:min-h-9">
|
||||
<Checkbox
|
||||
checked={
|
||||
checkState === "all"
|
||||
? true
|
||||
: checkState === "mixed"
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={onToggleGroup}
|
||||
aria-label={label}
|
||||
data-testid={`report-group-check-${id}`}
|
||||
/>
|
||||
<span className="text-foreground min-w-0 flex-1 text-sm">{label}</span>
|
||||
<TagChip>
|
||||
{t("reportSelection.groupCount", {
|
||||
on: count.on,
|
||||
total: count.total,
|
||||
})}
|
||||
</TagChip>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
aria-label={t("reportSelection.groupDisclosure", { group: label })}
|
||||
onClick={onToggleOpen}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -m-1 flex size-8 shrink-0 items-center justify-center rounded-md transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn("size-4 transition-transform", open && "rotate-180")}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{open ? (
|
||||
<div id={panelId}>
|
||||
<LeafGrid
|
||||
t={t}
|
||||
leaves={leaves}
|
||||
selected={selected}
|
||||
onToggle={onToggleLeaf}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</ListRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The per-leaf checkbox grid inside an expanded group.
|
||||
*
|
||||
* A dense read-out, not a field grid: `gap-2` on the enforced scale, two
|
||||
* columns from the `sm` breakpoint. Each row clears the tap floor and carries
|
||||
* a stable test id so an end-to-end assertion binds to the leaf, not to text
|
||||
* that a breakpoint may hide.
|
||||
*/
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { MEASUREMENT_TYPE_ICONS } from "@/components/measurements/measurement-list-meta";
|
||||
import {
|
||||
leafLabelKey,
|
||||
type ReportLeafId,
|
||||
} from "@/lib/report-selection/catalogue";
|
||||
|
||||
export function LeafGrid({
|
||||
t,
|
||||
leaves,
|
||||
selected,
|
||||
onToggle,
|
||||
}: {
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
leaves: readonly ReportLeafId[];
|
||||
selected: ReadonlySet<ReportLeafId>;
|
||||
onToggle: (leaf: ReportLeafId) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{leaves.map((leaf) => {
|
||||
const Icon = MEASUREMENT_TYPE_ICONS[leaf];
|
||||
return (
|
||||
<label
|
||||
key={leaf}
|
||||
data-testid={`report-leaf-${leaf}`}
|
||||
className="flex min-h-11 items-center gap-3 text-sm sm:min-h-9"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected.has(leaf)}
|
||||
onCheckedChange={() => onToggle(leaf)}
|
||||
/>
|
||||
{Icon ? (
|
||||
<Icon
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
<span className="text-foreground min-w-0 flex-1">
|
||||
{t(leafLabelKey(leaf))}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The one scope picker. Both surfaces that choose what a report contains — the
|
||||
* export panel and the share-link create form — mount this exact component, so
|
||||
* two selection UIs cannot drift apart.
|
||||
*
|
||||
* Twelve group rows plus a fenced tier below a separator. The fenced tier has
|
||||
* NO group checkbox: there is no control anywhere in this component that can
|
||||
* turn on more than one sensitive leaf at a time, and there is no select-all.
|
||||
* The fence uses no colour to mark itself — a separator, a heading, and
|
||||
* per-leaf controls. A red tint would be decoration; status colour is reserved
|
||||
* for meaning.
|
||||
*
|
||||
* The component is controlled: it owns no selection state, only which rows are
|
||||
* expanded and what each group looked like before it was switched off.
|
||||
*/
|
||||
import { useId, useState } from "react";
|
||||
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
REPORT_GROUPS,
|
||||
SENSITIVE_GROUP_ID,
|
||||
type ReportGroupId,
|
||||
type ReportLeafId,
|
||||
} from "@/lib/report-selection/catalogue";
|
||||
import {
|
||||
groupCheckState,
|
||||
groupCount,
|
||||
toggleGroup,
|
||||
toggleLeaf,
|
||||
} from "@/lib/report-selection/panel-state";
|
||||
|
||||
import { GroupRow } from "./group-row";
|
||||
import { LeafGrid } from "./leaf-grid";
|
||||
|
||||
export function ReportScopePicker({
|
||||
selected,
|
||||
onChange,
|
||||
hiddenLeaves,
|
||||
}: {
|
||||
selected: ReadonlySet<ReportLeafId>;
|
||||
onChange: (next: Set<ReportLeafId>) => void;
|
||||
/**
|
||||
* Leaves this surface must not offer at all. The share-link form passes the
|
||||
* insurance leaf: the server refuses it with a 422, and a control that
|
||||
* cannot be honoured has no business rendering.
|
||||
*/
|
||||
hiddenLeaves?: readonly ReportLeafId[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const idPrefix = useId();
|
||||
const [openGroups, setOpenGroups] = useState<ReadonlySet<ReportGroupId>>(
|
||||
new Set(),
|
||||
);
|
||||
// What each group looked like before it was switched off, so switching it
|
||||
// back on inside one session restores the pattern instead of flooding it.
|
||||
const [remembered, setRemembered] = useState<ReadonlySet<ReportLeafId>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const hidden = new Set(hiddenLeaves ?? []);
|
||||
const groups = REPORT_GROUPS.map((group) => ({
|
||||
...group,
|
||||
leaves: group.leaves.filter((leaf) => !hidden.has(leaf)),
|
||||
})).filter((group) => group.leaves.length > 0);
|
||||
|
||||
const plainGroups = groups.filter((g) => g.id !== SENSITIVE_GROUP_ID);
|
||||
const sensitiveGroup = groups.find((g) => g.id === SENSITIVE_GROUP_ID);
|
||||
|
||||
const handleLeaf = (leaf: ReportLeafId) => {
|
||||
setRemembered(new Set(selected));
|
||||
onChange(toggleLeaf(selected, leaf));
|
||||
};
|
||||
|
||||
const handleGroup = (group: ReportGroupId) => {
|
||||
setRemembered(new Set(selected));
|
||||
onChange(toggleGroup(selected, group, remembered));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-testid="report-scope-picker">
|
||||
{plainGroups.map((group) => (
|
||||
<GroupRow
|
||||
key={group.id}
|
||||
t={t}
|
||||
id={group.id}
|
||||
labelKey={group.labelKey}
|
||||
leaves={group.leaves}
|
||||
selected={selected}
|
||||
checkState={groupCheckState(group.id, selected)}
|
||||
count={groupCount(group.id, selected)}
|
||||
open={openGroups.has(group.id)}
|
||||
panelId={`${idPrefix}-${group.id}`}
|
||||
onToggleGroup={() => handleGroup(group.id)}
|
||||
onToggleLeaf={handleLeaf}
|
||||
onToggleOpen={() =>
|
||||
setOpenGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(group.id)) next.delete(group.id);
|
||||
else next.add(group.id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{sensitiveGroup ? (
|
||||
<div data-testid="report-sensitive-tier">
|
||||
<Separator className="my-4" />
|
||||
<p className="text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">
|
||||
{t(sensitiveGroup.labelKey)}
|
||||
</p>
|
||||
<p className="text-muted-foreground mb-2 text-xs">
|
||||
{t("reportSelection.sensitiveHint")}
|
||||
</p>
|
||||
<LeafGrid
|
||||
t={t}
|
||||
leaves={sensitiveGroup.leaves}
|
||||
selected={selected}
|
||||
onToggle={handleLeaf}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The one line that says what the artefact will contain.
|
||||
*
|
||||
* This is the consent check: reading it IS the review. Fenced inclusions are
|
||||
* restated by name rather than folded into the count, because a number cannot
|
||||
* be checked at a glance and "incl. Mood, PHQ-9" can.
|
||||
*
|
||||
* Meta tier — one sentence about the form beside it, not content.
|
||||
*/
|
||||
import { scopeSummary } from "@/lib/report-selection/panel-state";
|
||||
import type { ReportLeafId } from "@/lib/report-selection/catalogue";
|
||||
|
||||
export function ScopeSummary({
|
||||
t,
|
||||
selected,
|
||||
}: {
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
selected: ReadonlySet<ReportLeafId>;
|
||||
}) {
|
||||
const summary = scopeSummary(selected);
|
||||
if (summary.total === 0) {
|
||||
return (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid="report-scope-summary"
|
||||
>
|
||||
{t("reportSelection.scopeNone")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const base = t("reportSelection.scopeCount", {
|
||||
count: summary.total,
|
||||
groups: summary.groups,
|
||||
});
|
||||
const sensitive =
|
||||
summary.sensitiveLabelKeys.length > 0
|
||||
? t("reportSelection.scopeSensitive", {
|
||||
names: summary.sensitiveLabelKeys.map((key) => t(key)).join(", "),
|
||||
})
|
||||
: null;
|
||||
return (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid="report-scope-summary"
|
||||
>
|
||||
{sensitive ? `${base} · ${sensitive}` : base}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Settings → Export → the health-record export panel.
|
||||
*
|
||||
* One surface driving `POST /api/export/health-record`: a format radio, the
|
||||
* reporting window, the practice line, the scope picker, and the generate
|
||||
* action that streams the artefact as a download.
|
||||
*
|
||||
* Three states, by design:
|
||||
*
|
||||
* 1. First run (no saved selection): the picker opens EXPANDED with the
|
||||
* named standard template applied and the fenced tier visibly empty, so
|
||||
* the user presses Generate having seen twelve named groups. That is
|
||||
* consent to a named bundle, not a server-chosen default.
|
||||
* 2. Repeat run: the picker is collapsed behind its disclosure, the saved
|
||||
* selection is loaded, and the scope line under the button says what will
|
||||
* be in the document. Two interactions after navigation, and the reading
|
||||
* is the consent check.
|
||||
* 3. Editing: a group row is one control for up to sixteen leaves; the leaf
|
||||
* grid opens only when the scope actually changes, which is rare.
|
||||
*
|
||||
* The saved selection seeds per-user during render with a stored-id guard —
|
||||
* `useAuth` resolves asynchronously, so a `useState` initialiser would read
|
||||
* `undefined` and stick — and the per-user gate means a late re-resolve never
|
||||
* clobbers edits the user is in the middle of.
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import { useId, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ChevronDown,
|
||||
Download,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { Switch } from "@/components/ui/switch";
|
||||
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReportLeafId } from "@/lib/report-selection/catalogue";
|
||||
import { parseSavedProfile } from "@/lib/report-selection/saved-profile";
|
||||
import { STANDARD_TEMPLATE_LEAVES } from "@/lib/report-selection/template";
|
||||
import { orderLeaves } from "@/lib/report-selection/selection";
|
||||
|
||||
import { ReportScopePicker } from "./report-scope-picker";
|
||||
import { ScopeSummary } from "./scope-summary";
|
||||
|
||||
type ExportFormat = "pdf" | "fhir" | "package";
|
||||
|
||||
const EXPORT_FORMATS: readonly ExportFormat[] = ["pdf", "fhir", "package"];
|
||||
const PRESET_RANGES = [30, 90, 180, 365] as const;
|
||||
|
||||
export function HealthRecordExportPanel() {
|
||||
const { t, locale } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [format, setFormat] = useState<ExportFormat>("pdf");
|
||||
const [days, setDays] = useState<number>(90);
|
||||
const [customRange, setCustomRange] = useState(false);
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [practiceName, setPracticeName] = useState("");
|
||||
const [includeCharts, setIncludeCharts] = useState(true);
|
||||
const [selected, setSelected] = useState<ReadonlySet<ReportLeafId>>(
|
||||
() => new Set(STANDARD_TEMPLATE_LEAVES),
|
||||
);
|
||||
const [seededUserId, setSeededUserId] = useState<string | null>(null);
|
||||
// First run opens the picker so the template is seen, not merely applied.
|
||||
const [pickerOpen, setPickerOpen] = useState(true);
|
||||
const [templateApplied, setTemplateApplied] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const pickerPanelId = useId();
|
||||
|
||||
if (user && user.id !== seededUserId) {
|
||||
setSeededUserId(user.id);
|
||||
setPracticeName(user.lastReportPracticeName ?? "");
|
||||
const saved = parseSavedProfile(user.reportSelection);
|
||||
if (saved) {
|
||||
setSelected(new Set(saved.leaves as ReportLeafId[]));
|
||||
setFormat(saved.format);
|
||||
setDays(saved.rangeDays);
|
||||
setIncludeCharts(saved.includeCharts);
|
||||
setPickerOpen(false);
|
||||
setTemplateApplied(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isPdfLike = format === "pdf" || format === "package";
|
||||
const isFhirLike = format === "fhir" || format === "package";
|
||||
|
||||
const { getRadioProps: getFormatRadioProps } = useRovingRadioGroup({
|
||||
count: EXPORT_FORMATS.length,
|
||||
selectedIndex: EXPORT_FORMATS.indexOf(format),
|
||||
onSelect: (index) => setFormat(EXPORT_FORMATS[index]!),
|
||||
});
|
||||
|
||||
async function handleGenerate() {
|
||||
if (selected.size === 0) {
|
||||
setError(t("settings.healthRecord.errorEmptySelection"));
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const range =
|
||||
customRange && startDate && endDate
|
||||
? {
|
||||
startDate: new Date(`${startDate}T00:00:00Z`).toISOString(),
|
||||
endDate: new Date(`${endDate}T23:59:59Z`).toISOString(),
|
||||
}
|
||||
: { days };
|
||||
const res = await apiFetchRaw("/api/export/health-record", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
format,
|
||||
// Carry the active in-app locale so the artefact matches the UI
|
||||
// language instead of falling back to Accept-Language on the server.
|
||||
locale,
|
||||
range,
|
||||
practiceName: practiceName.trim() || undefined,
|
||||
includeCharts,
|
||||
selection: { v: 2, leaves: orderLeaves(selected) },
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError(
|
||||
res.status === 429
|
||||
? t("settings.healthRecord.errorRateLimit")
|
||||
: res.status === 403
|
||||
? t("settings.healthRecord.errorModuleDisabled")
|
||||
: t("settings.healthRecord.errorGeneric"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const ext = format === "pdf" ? "pdf" : format === "fhir" ? "json" : "zip";
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `healthlog-health-record-${new Date()
|
||||
.toISOString()
|
||||
.slice(0, 10)}.${ext}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
setTemplateApplied(false);
|
||||
// The route just persisted the practice name and the selection, so the
|
||||
// cached `/me` payload is stale. Refresh it, or a remount would seed from
|
||||
// the previous values.
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
as="section"
|
||||
aria-labelledby="health-record-export-title"
|
||||
data-testid="health-record-export-panel"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
className="mb-4"
|
||||
icon={FileText}
|
||||
titleId="health-record-export-title"
|
||||
title={t("settings.healthRecord.title")}
|
||||
description={t("settings.healthRecord.description")}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Format + range share a row on desktop; on mobile they stack. */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<fieldset className="space-y-1.5">
|
||||
<legend className="mb-1 text-sm font-medium">
|
||||
{t("settings.healthRecord.format")}
|
||||
</legend>
|
||||
<div className="flex flex-wrap gap-2" role="radiogroup">
|
||||
{EXPORT_FORMATS.map((f, index) => (
|
||||
<Button
|
||||
key={f}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={format === f}
|
||||
variant={format === f ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setFormat(f)}
|
||||
{...getFormatRadioProps(index)}
|
||||
>
|
||||
{t(`settings.healthRecord.format_${f}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hr-range">{t("settings.healthRecord.range")}</Label>
|
||||
<NativeSelect
|
||||
id="hr-range"
|
||||
value={customRange ? "custom" : String(days)}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === "custom") {
|
||||
setCustomRange(true);
|
||||
return;
|
||||
}
|
||||
setCustomRange(false);
|
||||
setDays(Number(e.target.value));
|
||||
}}
|
||||
>
|
||||
{PRESET_RANGES.map((preset) => (
|
||||
<option key={preset} value={String(preset)}>
|
||||
{t(`settings.healthRecord.range${preset}`)}
|
||||
</option>
|
||||
))}
|
||||
<option value="custom">
|
||||
{t("settings.healthRecord.rangeCustom")}
|
||||
</option>
|
||||
</NativeSelect>
|
||||
{/* Entry point into the document vault for the report period
|
||||
(navigation only). Only rendered when the module is enabled. */}
|
||||
{user?.modules?.inboundDocuments ? (
|
||||
<Link
|
||||
href={`/documents?year=${new Date().getFullYear()}`}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 rounded-md text-xs transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
<FolderOpen className="size-3.5" aria-hidden />
|
||||
{t("settings.healthRecord.documentsLink")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{customRange ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hr-range-start">
|
||||
{t("settings.healthRecord.rangeCustomStart")}
|
||||
</Label>
|
||||
<DateField
|
||||
id="hr-range-start"
|
||||
value={startDate}
|
||||
onChange={setStartDate}
|
||||
max={endDate || undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hr-range-end">
|
||||
{t("settings.healthRecord.rangeCustomEnd")}
|
||||
</Label>
|
||||
<DateField
|
||||
id="hr-range-end"
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
min={startDate || undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Practice name — PDF only */}
|
||||
{isPdfLike && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="hr-practice">
|
||||
{t("settings.healthRecord.practiceName")}
|
||||
</Label>
|
||||
<Input
|
||||
id="hr-practice"
|
||||
value={practiceName}
|
||||
onChange={(e) => setPracticeName(e.target.value)}
|
||||
maxLength={120}
|
||||
placeholder={t("settings.healthRecord.practiceNamePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="sr-only">
|
||||
{t("settings.healthRecord.includedData")}
|
||||
</legend>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="health-record-included-data-toggle"
|
||||
aria-expanded={pickerOpen}
|
||||
aria-controls={pickerPanelId}
|
||||
onClick={() => setPickerOpen((v) => !v)}
|
||||
className="text-foreground hover:bg-muted/40 focus-visible:ring-ring/50 flex min-h-11 w-full items-center justify-between gap-3 rounded-lg px-1 py-1 text-left text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<span>{t("settings.healthRecord.includedData")}</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"text-muted-foreground h-4 w-4 shrink-0 transition-transform",
|
||||
pickerOpen && "rotate-180",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{pickerOpen && (
|
||||
<div
|
||||
id={pickerPanelId}
|
||||
data-testid="health-record-included-data-panel"
|
||||
className="animate-insight-in space-y-3"
|
||||
style={{ animationDuration: "200ms" }}
|
||||
>
|
||||
{templateApplied ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid="report-template-note"
|
||||
>
|
||||
{t("reportSelection.templateApplied", {
|
||||
template: t("reportSelection.templateStandard"),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<ReportScopePicker selected={selected} onChange={setSelected} />
|
||||
{isPdfLike ? (
|
||||
<label className="flex min-h-11 items-center justify-between gap-3 text-sm sm:min-h-9">
|
||||
<span className="text-foreground">
|
||||
{t("settings.healthRecord.includeCharts")}
|
||||
</span>
|
||||
<Switch
|
||||
checked={includeCharts}
|
||||
onCheckedChange={() => setIncludeCharts((v) => !v)}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
{/* The FHIR note and the generate action share the footer row. */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<ScopeSummary t={t} selected={selected} />
|
||||
{isFhirLike ? (
|
||||
<p className="text-muted-foreground max-w-md text-xs">
|
||||
{t("settings.healthRecord.fhirNote")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={busy}
|
||||
className="ml-auto min-h-11 shrink-0 sm:min-h-9"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
{t("settings.healthRecord.generate")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
@@ -32,6 +32,11 @@ import {
|
||||
ShareDocumentPicker,
|
||||
type PickedDocument,
|
||||
} from "@/components/settings/share-document-picker";
|
||||
import { ReportScopePicker } from "@/components/settings/report-selection/report-scope-picker";
|
||||
import { ScopeSummary } from "@/components/settings/report-selection/scope-summary";
|
||||
import type { ReportLeafId } from "@/lib/report-selection/catalogue";
|
||||
import { orderLeaves } from "@/lib/report-selection/selection";
|
||||
import { STANDARD_TEMPLATE_LEAVES } from "@/lib/report-selection/template";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
@@ -58,6 +63,13 @@ export interface ShareLinkSummary {
|
||||
protected: boolean;
|
||||
/** v1.28 — size of the frozen document set (never the ids, never bytes). */
|
||||
documentCount: number;
|
||||
/**
|
||||
* Whether this link's frozen scope predates the selection model. Such links
|
||||
* were revoked on upgrade — their scope was "everything except mood and
|
||||
* cycle", which nobody chose — and the sharing list says so, so the owner
|
||||
* re-mints rather than wondering why a working link stopped.
|
||||
*/
|
||||
needsReselection: boolean;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
revokedAt: string | null;
|
||||
@@ -72,6 +84,14 @@ export interface ShareLinkSummary {
|
||||
* hashes). `qrUrl` carries the passphrase in the URL fragment (`#k=`).
|
||||
*/
|
||||
export interface ShareLinkCreated extends ShareLinkSummary {
|
||||
/**
|
||||
* RETIRED, response-only. They configured a FHIR face that was never built;
|
||||
* the request refuses them and the columns are gone. They stay here because
|
||||
* the native client was given this shape and a missing non-optional key
|
||||
* fails a whole Swift decode — see #70 in the native repository.
|
||||
*/
|
||||
allowFhirApi: boolean;
|
||||
resourceTypes: string[];
|
||||
token: string;
|
||||
passphrase: string;
|
||||
shareUrl: string;
|
||||
@@ -87,17 +107,25 @@ export interface ShareLinkCreatePayload {
|
||||
label: string;
|
||||
rangeStart: string;
|
||||
rangeEnd: null;
|
||||
selection?: { v: 2; leaves: readonly string[] };
|
||||
documentIds?: string[];
|
||||
documentOnly?: boolean;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one leaf a share link may never carry — mirrored from the server, which
|
||||
* refuses it with a 422. The picker hides it rather than rendering a control
|
||||
* that cannot be honoured.
|
||||
*/
|
||||
const SHARE_HIDDEN_LEAVES: readonly ReportLeafId[] = ["INSURANCE"];
|
||||
|
||||
/**
|
||||
* Build the create payload from the resolved form state. Extracted as a pure
|
||||
* function so the two surfaces' scope contract is unit-testable without a DOM.
|
||||
*
|
||||
* The `documentOnly` branch is the privacy-load-bearing one: a document-launched
|
||||
* share posts an EMPTY report scope (no `sections`) plus `documentOnly: true`,
|
||||
* share posts NO report scope at all plus `documentOnly: true`,
|
||||
* and ALWAYS carries the picked document ids —
|
||||
* so the created link serves the document(s) and nothing else. The record share
|
||||
* (Settings → Sharing) keeps its full scope and only attaches documents when
|
||||
@@ -107,6 +135,7 @@ export function buildShareLinkCreatePayload(input: {
|
||||
label: string;
|
||||
rangeDays: number;
|
||||
expiryDays: number;
|
||||
leaves: readonly ReportLeafId[];
|
||||
documentIds: string[];
|
||||
documentOnly: boolean;
|
||||
}): ShareLinkCreatePayload {
|
||||
@@ -127,6 +156,7 @@ export function buildShareLinkCreatePayload(input: {
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
selection: { v: 2, leaves: input.leaves },
|
||||
// Omit the key entirely when nothing is attached (a documents-less record
|
||||
// share stays the default). The server re-validates each id as the caller's
|
||||
// own live document before minting the link.
|
||||
@@ -163,6 +193,18 @@ export function ShareLinkCreateForm({
|
||||
const [selectedDocs, setSelectedDocs] = useState<PickedDocument[]>(
|
||||
initialDocuments ?? [],
|
||||
);
|
||||
// The named standard template is the starting point for a record share, the
|
||||
// same one the export panel opens with, minus the leaf a link may not carry.
|
||||
const [selectedLeaves, setSelectedLeaves] = useState<
|
||||
ReadonlySet<ReportLeafId>
|
||||
>(
|
||||
() =>
|
||||
new Set(
|
||||
STANDARD_TEMPLATE_LEAVES.filter(
|
||||
(leaf) => !SHARE_HIDDEN_LEAVES.includes(leaf),
|
||||
),
|
||||
),
|
||||
);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [created, setCreated] = useState<ShareLinkCreated | null>(null);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
@@ -212,6 +254,7 @@ export function ShareLinkCreateForm({
|
||||
label,
|
||||
rangeDays,
|
||||
expiryDays,
|
||||
leaves: orderLeaves(selectedLeaves),
|
||||
documentIds: selectedDocs.map((d) => d.id),
|
||||
documentOnly,
|
||||
}),
|
||||
@@ -310,6 +353,26 @@ export function ShareLinkCreateForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── What the link contains ───────────────────────────────── */}
|
||||
{!documentOnly ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">
|
||||
{t("settings.sharing.scopeTitle")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.sharing.scopeInsuranceExcluded")}
|
||||
</p>
|
||||
</div>
|
||||
<ReportScopePicker
|
||||
selected={selectedLeaves}
|
||||
onChange={setSelectedLeaves}
|
||||
hiddenLeaves={SHARE_HIDDEN_LEAVES}
|
||||
/>
|
||||
<ScopeSummary t={t} selected={selectedLeaves} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ── Attach documents ─────────────────────────────────────── */}
|
||||
<div className="border-border space-y-2 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
@@ -384,7 +447,11 @@ export function ShareLinkCreateForm({
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || !label.trim()}
|
||||
disabled={
|
||||
createMutation.isPending ||
|
||||
!label.trim() ||
|
||||
(!documentOnly && selectedLeaves.size === 0)
|
||||
}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{createMutation.isPending && (
|
||||
|
||||
@@ -236,11 +236,25 @@ function ShareLinksCard() {
|
||||
{link.label}
|
||||
</p>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{link.revokedAt
|
||||
? t("settings.sharing.statusRevoked")
|
||||
: t("settings.sharing.statusExpired")}
|
||||
{link.needsReselection
|
||||
? t("settings.sharing.statusNeedsReselect")
|
||||
: link.revokedAt
|
||||
? t("settings.sharing.statusRevoked")
|
||||
: t("settings.sharing.statusExpired")}
|
||||
</Badge>
|
||||
</div>
|
||||
{/* A link whose frozen scope predates the selection model.
|
||||
It served a scope nobody chose, so it was closed; the
|
||||
owner re-mints above with the label, window and expiry
|
||||
still in front of them. */}
|
||||
{link.needsReselection ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid={`share-needs-reselect-${link.id}`}
|
||||
>
|
||||
{t("settings.sharing.reselectReason")}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium">
|
||||
{t("settings.sharing.accessCount")}:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui";
|
||||
import { Check } from "lucide-react";
|
||||
import { Check, Minus } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -16,6 +16,12 @@ import { cn } from "@/lib/utils";
|
||||
* Mirrors the v1.4.27 shadcn `<Checkbox>` recipe: a Root that draws
|
||||
* the border + checked-state background, an Indicator slot that
|
||||
* shows the lucide `<Check>` when the box is checked.
|
||||
*
|
||||
* The Indicator also draws a `<Minus>` for the indeterminate state, which
|
||||
* Radix's Root accepts natively via `checked="indeterminate"`. A group row
|
||||
* whose leaves are partly selected is a real third state and needs to look
|
||||
* like one — drawing it as unchecked would say "none of this is included",
|
||||
* which on this surface is a false statement about what leaves the instance.
|
||||
*/
|
||||
function Checkbox({
|
||||
className,
|
||||
@@ -34,6 +40,7 @@ function Checkbox({
|
||||
// blanket neighbouring controls in dense table rows.
|
||||
"peer border-border/70 relative size-4 shrink-0 rounded border before:absolute before:inset-[-8px] before:content-['']",
|
||||
"data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-background",
|
||||
"data-[state=indeterminate]:bg-primary data-[state=indeterminate]:border-primary data-[state=indeterminate]:text-background",
|
||||
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"transition-colors",
|
||||
@@ -45,7 +52,11 @@ function Checkbox({
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current"
|
||||
>
|
||||
<Check className="size-3" aria-hidden="true" />
|
||||
{props.checked === "indeterminate" ? (
|
||||
<Minus className="size-3" aria-hidden="true" />
|
||||
) : (
|
||||
<Check className="size-3" aria-hidden="true" />
|
||||
)}
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
|
||||
@@ -123,6 +123,13 @@ export interface AuthUser {
|
||||
* user has never set one, or deliberately cleared the line.
|
||||
*/
|
||||
lastReportPracticeName: string | null;
|
||||
/**
|
||||
* The saved report selection blob (`{ v, leaves, format, rangeDays,
|
||||
* includeCharts }`), or `null` when the account has never saved one. Parsed
|
||||
* through `parseSavedProfile` at the one place that reads it, so a drifted
|
||||
* shape resolves to "never saved" rather than to a partial scope.
|
||||
*/
|
||||
reportSelection: unknown;
|
||||
/**
|
||||
* v1.15.0 — cycle-tracking feature gate, resolved server-side from gender
|
||||
* + the per-user opt-in. The cycle nav entry + page hide when false; every
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* The pure state of the report scope picker.
|
||||
*
|
||||
* Kept out of the client component so the control shape is testable without a
|
||||
* DOM, and so the structural guard can import a lib module rather than reach
|
||||
* into a component. The panel owns layout and interaction; this owns "which
|
||||
* controls exist, what each one means, and what the scope line says".
|
||||
*
|
||||
* Only leaves are ever stored. A group's state is DERIVED from its leaves, so
|
||||
* a group and its leaves cannot disagree — there is no second source of truth
|
||||
* to reconcile.
|
||||
*/
|
||||
import {
|
||||
REPORT_GROUPS,
|
||||
SENSITIVE_GROUP_ID,
|
||||
leafLabelKey,
|
||||
type ReportGroupId,
|
||||
type ReportLeafId,
|
||||
} from "./catalogue";
|
||||
|
||||
/** The panel's own state: the set of chosen leaf ids. */
|
||||
export type PanelSelection = ReadonlySet<ReportLeafId>;
|
||||
|
||||
/** A group checkbox is on, off, or partly on. `mixed` is never a click target. */
|
||||
export type GroupCheckState = "all" | "none" | "mixed";
|
||||
|
||||
export function groupCheckState(
|
||||
group: ReportGroupId,
|
||||
selected: PanelSelection,
|
||||
): GroupCheckState {
|
||||
const leaves = REPORT_GROUPS.find((g) => g.id === group)?.leaves ?? [];
|
||||
if (leaves.length === 0) return "none";
|
||||
const on = leaves.filter((leaf) => selected.has(leaf)).length;
|
||||
if (on === 0) return "none";
|
||||
if (on === leaves.length) return "all";
|
||||
return "mixed";
|
||||
}
|
||||
|
||||
/** How many of a group's leaves are chosen, and how many there are. */
|
||||
export function groupCount(
|
||||
group: ReportGroupId,
|
||||
selected: PanelSelection,
|
||||
): { on: number; total: number } {
|
||||
const leaves = REPORT_GROUPS.find((g) => g.id === group)?.leaves ?? [];
|
||||
return {
|
||||
on: leaves.filter((leaf) => selected.has(leaf)).length,
|
||||
total: leaves.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Toggle one leaf. */
|
||||
export function toggleLeaf(
|
||||
selected: PanelSelection,
|
||||
leaf: ReportLeafId,
|
||||
): Set<ReportLeafId> {
|
||||
const next = new Set(selected);
|
||||
if (next.has(leaf)) next.delete(leaf);
|
||||
else next.add(leaf);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a whole group.
|
||||
*
|
||||
* `mixed` and `none` both go to all-on; `all` goes to all-off. Turning a group
|
||||
* off and back on within one session restores the previous leaf pattern via
|
||||
* `remembered`, so the escape hatch does not cost the user their earlier
|
||||
* refinements. Across sessions only leaves persist and the group state is
|
||||
* re-derived.
|
||||
*
|
||||
* The fenced tier has no group control at all — `REPORT_GROUPS` still lists
|
||||
* it, but the panel renders no checkbox for it, and this function refuses it
|
||||
* so no future caller can route around that.
|
||||
*/
|
||||
export function toggleGroup(
|
||||
selected: PanelSelection,
|
||||
group: ReportGroupId,
|
||||
remembered?: PanelSelection,
|
||||
): Set<ReportLeafId> {
|
||||
if (group === SENSITIVE_GROUP_ID) return new Set(selected);
|
||||
const leaves = REPORT_GROUPS.find((g) => g.id === group)?.leaves ?? [];
|
||||
const next = new Set(selected);
|
||||
if (groupCheckState(group, selected) === "all") {
|
||||
for (const leaf of leaves) next.delete(leaf);
|
||||
return next;
|
||||
}
|
||||
const restore = remembered
|
||||
? leaves.filter((leaf) => remembered.has(leaf))
|
||||
: [];
|
||||
for (const leaf of restore.length > 0 ? restore : leaves) next.add(leaf);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The scope line under the Generate button, as the parts a component renders.
|
||||
*
|
||||
* Fenced inclusions are always restated BY NAME, never folded into the count.
|
||||
* A number cannot be checked at a glance; "incl. Mood, PHQ-9" can, and the
|
||||
* reading is the consent check.
|
||||
*/
|
||||
export interface ScopeSummary {
|
||||
/** How many leaves are chosen in total. */
|
||||
total: number;
|
||||
/** How many groups contribute at least one leaf. */
|
||||
groups: number;
|
||||
/** i18n label keys for the chosen fenced leaves, in catalogue order. */
|
||||
sensitiveLabelKeys: string[];
|
||||
}
|
||||
|
||||
export function scopeSummary(selected: PanelSelection): ScopeSummary {
|
||||
let total = 0;
|
||||
let groups = 0;
|
||||
const sensitiveLabelKeys: string[] = [];
|
||||
for (const group of REPORT_GROUPS) {
|
||||
const on = group.leaves.filter((leaf) => selected.has(leaf));
|
||||
if (on.length === 0) continue;
|
||||
groups += 1;
|
||||
total += on.length;
|
||||
if (group.id === SENSITIVE_GROUP_ID) {
|
||||
sensitiveLabelKeys.push(...on.map(leafLabelKey));
|
||||
}
|
||||
}
|
||||
return { total, groups, sensitiveLabelKeys };
|
||||
}
|
||||
Reference in new issue
Block a user