Files
HealthLog/src/components/settings/health-record-export-panel.tsx
T
Marc-André Bombeck c6e782cdb8 fix(report): pass the in-app locale through to the pdf export
The health-record export panel sent no locale, so the server resolved the
report language from the browser's Accept-Language header. A German user on
an English-default browser got an English doctor report.

Send the active in-app locale from the panel, and on the server resolve
most-specific-first: explicit selection, then the healthlog-locale cookie
(what the user actually chose in the UI), then Accept-Language as the
weakest fallback.
2026-05-31 22:47:44 +02:00

425 lines
14 KiB
TypeScript

"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.
*/
import { useState } from "react";
import { Download, FileText, Loader2 } from "lucide-react";
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 { useTranslations } from "@/lib/i18n/context";
type ExportFormat = "pdf" | "fhir" | "package";
const EXPORT_FORMATS: readonly ExportFormat[] = ["pdf", "fhir", "package"];
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;
}
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,
};
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,
};
}
export function HealthRecordExportPanel() {
const { t, locale } = useTranslations();
const [format, setFormat] = useState<ExportFormat>("pdf");
const [days, setDays] = useState<number>(90);
const [practiceName, setPracticeName] = useState("");
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);
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.
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 fetch("/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);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
<section
aria-labelledby="health-record-export-title"
data-testid="health-record-export-panel"
className="bg-card border-border rounded-xl border p-6"
>
<div className="mb-4 flex items-center gap-2">
<FileText className="text-primary h-5 w-5" aria-hidden="true" />
<h2 id="health-record-export-title" className="text-lg font-semibold">
{t("settings.healthRecord.title")}
</h2>
</div>
<p className="text-muted-foreground mb-4 text-sm">
{t("settings.healthRecord.description")}
</p>
<div className="space-y-5">
{/* Format toggle */}
<fieldset className="space-y-2">
<legend className="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"}
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-2">
<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>
</div>
{/* Practice name — PDF only */}
{isPdfLike && (
<div className="space-y-2">
<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 */}
<fieldset className="space-y-3">
<legend className="text-sm font-medium">
{t("settings.healthRecord.includedData")}
</legend>
<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")}
/>
</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)}
/>
)}
</fieldset>
{isFhirLike && (
<p className="text-muted-foreground text-xs">
{t("settings.healthRecord.fhirNote")}
</p>
)}
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
onClick={handleGenerate}
disabled={busy}
className="min-h-11 sm:min-h-9"
>
{busy ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Download className="mr-2 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>
</section>
);
}
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>
);
}