Files
HealthLog/src/components/settings/export-section.tsx
T
Marc-André Bombeck 582db8a538 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.
2026-07-26 00:45:08 +02:00

551 lines
17 KiB
TypeScript

"use client";
/**
* v1.4.16 phase B7 — Settings → Export.
*
* Single surface that consolidates every "give me my data out" path in
* HealthLog. Replaces the old `<ExportCard>` inside `<AdvancedSection>`
* (CSV/JSON/Doctor-report buttons crammed onto one row) with one card
* per export type, each with its own filter inputs and a clear
* "Download" / "Generate" button.
*
* 1. Measurements CSV — optional `since`/`until`
* 2. Medications CSV — optional intake-history toggle
* 3. Mood CSV — optional `since`/`until`
* 4. Full JSON Backup — single-file user-scoped dump
*
* v1.18.0 (S5) — the full health-record export (PDF + FHIR R4 + zip
* package) moved out to its own top-level "Gesundheitsakte" section. This
* page now keeps only the generic data export/import paths.
*
* Mobile-first: cards stack on `<md`, two-column grid on `>=md`.
*/
import { useState } from "react";
import {
Download,
FileJson,
FileSpreadsheet,
FileText,
Loader2,
Pill,
Waves,
type LucideIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { DateField } from "@/components/ui/date-field";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
import { Switch } from "@/components/ui/switch";
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 { useTranslations } from "@/lib/i18n/context";
import { apiFetchRaw } from "@/lib/api/api-fetch";
type ExportFormat = "CSV" | "JSON" | "FHIR";
/**
* Trigger a browser download for a given URL by spinning up an anchor
* element. Used by every CSV/JSON card so the implementation stays in
* one place.
*/
async function downloadFromUrl(url: string, filename: string): Promise<void> {
const res = await apiFetchRaw(url, { credentials: "include" });
if (!res.ok) {
throw new Error(`Download failed (${res.status})`);
}
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objectUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
}
export function ExportSection() {
const { t } = useTranslations();
// v1.18.6 (W9) — the visible page heading + subtitle now come from the
// shared `<SettingsSectionFrame>` in the route; the inner `<h2>` keeps its
// own "other options" subsection label.
return (
<div className="space-y-6">
{/* v1.18.0 (S5) — the full health-record export moved to its own
top-level "Gesundheitsakte" section. This page keeps the generic
CSV/JSON data-out paths and the import surface. */}
<section
aria-labelledby="settings-section-export-other-title"
className="space-y-3"
>
<h2
id="settings-section-export-other-title"
className="text-base font-semibold tracking-tight"
>
{t("settings.sections.export.otherOptionsHeading")}
</h2>
<div className="grid gap-4 md:grid-cols-2">
<MeasurementsCsvCard />
<MedicationsCsvCard />
<MoodCsvCard />
<FullBackupCard />
</div>
</section>
{/* R28 / issue #281 — the import surface for the Apple Health
`export.zip` and the generic JSON paths. The export routes had
backends with no UI until now. */}
<ImportPanel />
</div>
);
}
// ─────────────────────────── Card primitives ───────────────────────────
interface ExportCardShellProps {
testId: string;
icon: LucideIcon;
title: string;
description: string;
format: ExportFormat;
children?: React.ReactNode;
/** Footer slot — typically the "Download" button + status text. */
footer: React.ReactNode;
/** Extra grid-position classes applied to the outer card div. */
outerClassName?: string;
}
function ExportCardShell({
testId,
icon: Icon,
title,
description,
format,
children,
footer,
outerClassName,
}: ExportCardShellProps) {
return (
<SettingsCard
data-testid={testId}
className={cn("flex h-full flex-col", outerClassName)}
>
<SettingsCardHeader
icon={Icon}
title={title}
description={description}
status={
<span className="border-border text-muted-foreground rounded-full border px-2 py-0.5 text-xs font-medium tracking-wide uppercase">
{format}
</span>
}
/>
{children && <div className="mt-3 space-y-3">{children}</div>}
<div className="mt-4 flex flex-wrap items-center gap-3">{footer}</div>
</SettingsCard>
);
}
// ─────────────────────────── CSV cards ───────────────────────────
interface DateRangeFieldsProps {
since: string;
until: string;
setSince: (v: string) => void;
setUntil: (v: string) => void;
idPrefix: string;
}
function DateRangeFields({
since,
until,
setSince,
setUntil,
idPrefix,
}: DateRangeFieldsProps) {
const { t } = useTranslations();
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`${idPrefix}-since`} className="text-xs">
{t("settings.sections.export.filters.since")}
</Label>
<DateField
id={`${idPrefix}-since`}
value={since}
onChange={setSince}
max={until || undefined}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`${idPrefix}-until`} className="text-xs">
{t("settings.sections.export.filters.until")}
</Label>
<DateField
id={`${idPrefix}-until`}
value={until}
onChange={setUntil}
min={since || undefined}
/>
</div>
</div>
);
}
function buildQueryString(base: Record<string, string | undefined>): string {
const params = new URLSearchParams();
for (const [k, v] of Object.entries(base)) {
if (v) params.set(k, v);
}
const q = params.toString();
return q ? `?${q}` : "";
}
interface CsvCardProps {
testId: string;
actionTestId: string;
icon: LucideIcon;
titleKey: string;
descriptionKey: string;
endpoint: string;
filenamePrefix: string;
}
function CsvCard({
testId,
actionTestId,
icon,
titleKey,
descriptionKey,
endpoint,
filenamePrefix,
}: CsvCardProps) {
const { t } = useTranslations();
const [since, setSince] = useState("");
const [until, setUntil] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleDownload() {
setBusy(true);
setError(null);
try {
const query = buildQueryString({
since: since || undefined,
until: until || undefined,
});
const stamp = new Date().toISOString().slice(0, 10);
const filename = `${filenamePrefix}-${stamp}.csv`;
await downloadFromUrl(`${endpoint}${query}`, filename);
} catch {
setError(t("settings.sections.export.downloadFailed"));
} finally {
setBusy(false);
}
}
return (
<ExportCardShell
testId={testId}
icon={icon}
title={t(titleKey)}
description={t(descriptionKey)}
format="CSV"
footer={
<Button
data-testid={actionTestId}
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={handleDownload}
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>
}
>
<DateRangeFields
idPrefix={testId}
since={since}
until={until}
setSince={setSince}
setUntil={setUntil}
/>
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
</ExportCardShell>
);
}
function MeasurementsCsvCard() {
return (
<CsvCard
testId="export-card-measurements-csv"
actionTestId="export-action-measurements-csv"
icon={FileSpreadsheet}
titleKey="settings.sections.export.cards.measurementsCsv.title"
descriptionKey="settings.sections.export.cards.measurementsCsv.description"
endpoint="/api/export/measurements"
filenamePrefix="healthlog-measurements"
/>
);
}
function MedicationsCsvCard() {
// The intake-history toggle is local state on this card — we read it
// when the user clicks Download and append `&intake=true` to the URL.
const { t } = useTranslations();
const [since, setSince] = useState("");
const [until, setUntil] = useState("");
const [includeIntake, setIncludeIntake] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleDownload() {
setBusy(true);
setError(null);
try {
const query = buildQueryString({
since: since || undefined,
until: until || undefined,
intake: includeIntake ? "true" : "false",
});
const stamp = new Date().toISOString().slice(0, 10);
await downloadFromUrl(
`/api/export/medications${query}`,
`healthlog-medications-${stamp}.csv`,
);
} catch {
setError(t("settings.sections.export.downloadFailed"));
} finally {
setBusy(false);
}
}
return (
<ExportCardShell
testId="export-card-medications-csv"
icon={Pill}
title={t("settings.sections.export.cards.medicationsCsv.title")}
description={t(
"settings.sections.export.cards.medicationsCsv.description",
)}
format="CSV"
footer={
<Button
data-testid="export-action-medications-csv"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={handleDownload}
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>
}
>
<DateRangeFields
idPrefix="export-medications-csv"
since={since}
until={until}
setSince={setSince}
setUntil={setUntil}
/>
<label
htmlFor="export-medications-include-intake"
className="flex min-h-11 cursor-pointer items-center gap-3 text-xs"
>
<Switch
id="export-medications-include-intake"
data-testid="export-medications-include-intake"
checked={includeIntake}
onCheckedChange={setIncludeIntake}
/>
<span className="text-muted-foreground">
{t("settings.sections.export.cards.medicationsCsv.includeIntake")}
</span>
</label>
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
</ExportCardShell>
);
}
function MoodCsvCard() {
return (
<CsvCard
testId="export-card-mood-csv"
actionTestId="export-action-mood-csv"
icon={Waves}
titleKey="settings.sections.export.cards.moodCsv.title"
descriptionKey="settings.sections.export.cards.moodCsv.description"
endpoint="/api/export/mood"
filenamePrefix="healthlog-mood"
/>
);
}
const MIN_EXPORT_PASSPHRASE_LENGTH = 12;
function FullBackupCard() {
const { t } = useTranslations();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// v1.23 — opt-in passphrase encryption. When on, the download POSTs to
// /api/export/encrypted with the passphrase and saves an opaque `.hlx`
// archive instead of plaintext JSON.
const [encrypt, setEncrypt] = useState(false);
const [passphrase, setPassphrase] = useState("");
const passphraseTooShort =
encrypt && passphrase.length < MIN_EXPORT_PASSPHRASE_LENGTH;
async function handleDownload() {
setBusy(true);
setError(null);
try {
const stamp = new Date().toISOString().slice(0, 10);
if (encrypt) {
const res = await apiFetchRaw("/api/export/encrypted", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passphrase }),
});
if (res.status === 401) {
setError(t("settings.sections.export.cards.fullBackup.stepUpNeeded"));
return;
}
if (!res.ok) {
throw new Error(`Download failed (${res.status})`);
}
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objectUrl;
a.download = `healthlog-backup-${stamp}.hlx`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
} else {
await downloadFromUrl(
"/api/export/full-backup",
`healthlog-backup-${stamp}.json`,
);
}
} catch {
setError(t("settings.sections.export.downloadFailed"));
} finally {
setBusy(false);
}
}
return (
<ExportCardShell
testId="export-card-full-backup"
icon={FileJson}
title={t("settings.sections.export.cards.fullBackup.title")}
description={t("settings.sections.export.cards.fullBackup.description")}
format="JSON"
footer={
<Button
data-testid="export-action-full-backup"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={handleDownload}
disabled={busy || passphraseTooShort}
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<FileText className="h-3.5 w-3.5" />
)}
{t("settings.sections.export.actions.download")}
</Button>
}
>
{/* v1.28 backup-completeness — the description above lists what's
included; this line honestly discloses what isn't (document
binaries, workout GPS/sample series) rather than implying
"everything" is in the file. Always visible, not gated on the
encrypt toggle. */}
<p
data-testid="export-full-backup-scope-note"
className="text-muted-foreground text-xs"
>
{t("settings.sections.export.cards.fullBackup.scopeNote")}
</p>
<label
htmlFor="export-full-backup-encrypt"
className="flex min-h-11 cursor-pointer items-center gap-3 text-xs"
>
<Switch
id="export-full-backup-encrypt"
data-testid="export-full-backup-encrypt"
checked={encrypt}
onCheckedChange={(v) => {
setEncrypt(v);
setError(null);
if (!v) setPassphrase("");
}}
/>
<span className="text-muted-foreground">
{t("settings.sections.export.cards.fullBackup.encryptToggle")}
</span>
</label>
{encrypt && (
<div className="space-y-2">
<PasswordInput
data-testid="export-full-backup-passphrase"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
placeholder={t(
"settings.sections.export.cards.fullBackup.passphrasePlaceholder",
)}
autoComplete="new-password"
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
className="h-9 text-sm"
/>
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.cards.fullBackup.passphraseHelp")}
</p>
<p className="text-warning text-xs">
{t("settings.sections.export.cards.fullBackup.noRecoveryWarning")}
</p>
</div>
)}
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
</ExportCardShell>
);
}
// ─────────────────────────── Cycle export (gated) ───────────────────────────