"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 `` inside `` * (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`. */ import { useState } from "react"; import { Download, FileJson, FileSpreadsheet, FileText, Loader2, Pill, Waves, type LucideIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { SettingsCardActions } from "@/components/settings/_card-actions"; import { SectionHeading } from "@/components/ui/section-heading"; 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"; import { recentProofErrorMessage, throwIfReproofRequired, useRecentProof, } from "@/components/settings/security-section/use-recent-proof"; 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 { const res = await throwIfReproofRequired( 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 `` in the route; the inner `

` keeps its // own "other options" subsection label. return (
{/* 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. */}
{/* 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. */}
); } // ─────────────────────────── Card primitives ─────────────────────────── interface ExportCardShellProps { testId: string; icon: LucideIcon; title: string; description: string; format: ExportFormat; children?: React.ReactNode; /** The card's action row — the "Download" button, and nothing after it. */ 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 ( {format} } /> {children &&
{children}
} {footer}
); } // ─────────────────────────── 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 (
); } function buildQueryString(base: Record): 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(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 ( {busy ? ( ) : ( )} {t("settings.sections.export.actions.download")} } > {error && (

{error}

)}
); } function MeasurementsCsvCard() { return ( ); } 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(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 ( {busy ? ( ) : ( )} {t("settings.sections.export.actions.download")} } > {error && (

{error}

)}
); } function MoodCsvCard() { return ( ); } const MIN_EXPORT_PASSPHRASE_LENGTH = 12; function FullBackupCard() { const { t } = useTranslations(); const [busy, setBusy] = useState(false); const [error, setError] = useState(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; // The whole record leaves the server here, so it asks for a fresh proof // unless the session signed in or re-proved within five minutes. const recentProof = useRecentProof(); async function handleDownload() { setBusy(true); setError(null); try { await recentProof.run(downloadOnce); } catch (err) { setError( recentProofErrorMessage( err, t("settings.sections.export.downloadFailed"), ), ); } finally { setBusy(false); } } async function downloadOnce() { const stamp = new Date().toISOString().slice(0, 10); if (encrypt) { const res = await throwIfReproofRequired( await apiFetchRaw("/api/export/encrypted", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ passphrase }), }), ); 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`, ); } } return ( {busy ? ( ) : ( )} {t("settings.sections.export.actions.download")} } > {/* What is in the file, and the shape it has. This used to be the card's description — twelve enumerated record types in a muted `text-xs` meta slot, which is neither the tier nor the length that slot is for. */}

{t("settings.sections.export.cards.fullBackup.contents")}{" "} {t("settings.sections.export.cards.fullBackup.detail")}

{/* v1.28 backup-completeness — the paragraph 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. */}

{t("settings.sections.export.cards.fullBackup.scopeNote")}

{encrypt && (
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" />

{t("settings.sections.export.cards.fullBackup.passphraseHelp")}

{t("settings.sections.export.cards.fullBackup.noRecoveryWarning")}

)} {error && (

{error}

)} {recentProof.dialog}
); } // ─────────────────────────── Cycle export (gated) ───────────────────────────