From 621109cf200bd6c86eaccaecf9341270d7e78d5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 9 May 2026 23:49:38 +0200 Subject: [PATCH] feat(settings): export-section consolidates doctor-report, CSVs, JSON backup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the consolidated `` component for the new `/settings/export` route — one card per export type (Doctor Report PDF, Measurements CSV, Medications CSV with optional intake-history toggle, Mood CSV, Full JSON backup) with date-range filters where applicable. Mobile-first: cards stack on small viewports and switch to a 2-column grid on `md+`. Each card carries a stable `data-testid` so the e2e suite can target them without relying on the localised label. i18n keys live under `settings.sections.export.*` in EN + DE and the old "advanced.description" copy was tightened so the upcoming wiring commit can move the export entry-point out of `/settings/advanced`. Co-Authored-By: Claude Opus 4.7 (1M context) --- messages/de.json | 37 +- messages/en.json | 37 +- .../__tests__/export-section.test.tsx | 91 +++ src/components/settings/export-section.tsx | 543 ++++++++++++++++++ 4 files changed, 706 insertions(+), 2 deletions(-) create mode 100644 src/components/settings/__tests__/export-section.test.tsx create mode 100644 src/components/settings/export-section.tsx diff --git a/messages/de.json b/messages/de.json index 11a7dc5ab..92ecbd99d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -989,7 +989,42 @@ }, "advanced": { "title": "Erweitert", - "description": "Export, Import, Daten löschen." + "description": "Import, Daten löschen." + }, + "export": { + "title": "Export", + "description": "Lade deine Gesundheitsdaten als PDF, CSV oder vollständiges JSON-Backup herunter.", + "actions": { + "download": "Herunterladen" + }, + "filters": { + "since": "Von", + "until": "Bis" + }, + "cards": { + "doctorReport": { + "title": "Arztbericht", + "description": "Druckbares PDF für den Arzttermin — Vitalwerte, BMI, BD-Klassifikation, Medikamenten-Compliance, Stimmung.", + "action": "Konfigurieren & generieren" + }, + "measurementsCsv": { + "title": "Messwerte", + "description": "Gewicht, Blutdruck, Puls, Glukose und alle weiteren Messungen als CSV-Datei." + }, + "medicationsCsv": { + "title": "Medikamente", + "description": "Deine Medikamentenliste mit Dosierung, Zeitplan und (optional) der vollständigen Einnahmehistorie.", + "includeIntake": "Einnahmehistorie einschließen" + }, + "moodCsv": { + "title": "Stimmung", + "description": "Tägliche Stimmungs-Einträge mit Score, Tags und Zeitstempeln." + }, + "fullBackup": { + "title": "Voll-Backup", + "description": "Eine einzige JSON-Datei mit allen Daten — gleiches Format wie das wöchentliche Auto-Backup. Praktisch zum Selbst-Wiederherstellen via Admin-Upload." + } + } }, "about": { "title": "Über", diff --git a/messages/en.json b/messages/en.json index d386bc974..74a60313c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -989,7 +989,42 @@ }, "advanced": { "title": "Advanced", - "description": "Export, import, danger zone." + "description": "Import, danger zone." + }, + "export": { + "title": "Export", + "description": "Download your health data as PDF, CSV, or a full JSON backup.", + "actions": { + "download": "Download" + }, + "filters": { + "since": "From", + "until": "Until" + }, + "cards": { + "doctorReport": { + "title": "Doctor Report", + "description": "Printable PDF for the doctor's appointment — vitals, BMI, BP classification, medication compliance, mood.", + "action": "Configure & generate" + }, + "measurementsCsv": { + "title": "Measurements", + "description": "Weight, blood pressure, pulse, glucose, and every other measurement as comma-separated values." + }, + "medicationsCsv": { + "title": "Medications", + "description": "Your medication list with dosage, schedules, and (optionally) the full intake history.", + "includeIntake": "Include intake history" + }, + "moodCsv": { + "title": "Mood", + "description": "Daily mood entries with score, tags, and timestamps." + }, + "fullBackup": { + "title": "Full backup", + "description": "Single JSON file with everything — same shape as the weekly auto-backup. Useful for self-restore via admin upload." + } + } }, "about": { "title": "About", diff --git a/src/components/settings/__tests__/export-section.test.tsx b/src/components/settings/__tests__/export-section.test.tsx new file mode 100644 index 000000000..5dab1fb4e --- /dev/null +++ b/src/components/settings/__tests__/export-section.test.tsx @@ -0,0 +1,91 @@ +/** + * v1.4.16 Phase B7 — Settings → Export section. + * + * The acceptance criterion for the consolidated Export menu is "five cards": + * 1. Doctor Report (PDF) + * 2. Measurements CSV + * 3. Medications CSV (with optional intake-history toggle) + * 4. Mood CSV + * 5. Full JSON Backup + * + * Each card must surface a title, a 1-line description, and a download/generate + * button. SSR-only smoke test — interaction is exercised by the e2e suite. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings/export", +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: null, isLoading: false, refetch: vi.fn() }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", role: "USER" }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { ExportSection } from "../export-section"; + +function render(node: React.ReactElement, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +describe(" — SSR smoke", () => { + it("renders the section heading + description", () => { + const html = render(); + expect(html).toContain("Export"); + // Raw key never leaks past i18n. + expect(html).not.toContain("settings.sections.export."); + }); + + it("renders exactly five export cards", () => { + const html = render(); + // Each card carries a stable data-testid so the e2e suite can target + // them without relying on the localised label. + const ids = [ + "export-card-doctor-report", + "export-card-measurements-csv", + "export-card-medications-csv", + "export-card-mood-csv", + "export-card-full-backup", + ]; + for (const id of ids) { + expect(html).toContain(`data-testid="${id}"`); + } + }); + + it("renders the medications-CSV intake-history toggle", () => { + const html = render(); + // The medications card has a checkbox/toggle for "include intake + // history" so the user can pick a smaller export when they only + // want the medication list. + expect(html).toContain('data-testid="export-medications-include-intake"'); + }); + + it("renders a download/generate button per card", () => { + const html = render(); + // Buttons are typed `button` and decorated with a stable testid so a + // single regex finds all five — the labels themselves are i18n'd. + const buttonMatches = html.match(/data-testid="export-action-/g); + expect(buttonMatches?.length ?? 0).toBe(5); + }); + + it("German locale renders the DE heading", () => { + const html = render(, "de"); + expect(html).toContain("Export"); + expect(html).not.toContain("settings.sections.export."); + }); +}); diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx new file mode 100644 index 000000000..7bb34f49e --- /dev/null +++ b/src/components/settings/export-section.tsx @@ -0,0 +1,543 @@ +"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. Doctor Report (PDF) — date range + practice name dialog + * 2. Measurements CSV — optional `since`/`until` + * 3. Medications CSV — optional intake-history toggle + * 4. Mood CSV — optional `since`/`until` + * 5. Full JSON Backup — single-file user-scoped dump + * + * Mobile-first: cards stack on `=md`. + */ + +import { useEffect, useState } from "react"; +import { + Download, + FileJson, + FileSpreadsheet, + FileText, + Loader2, + Pill, + Stethoscope, + Waves, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + DoctorReportDialog, + type DoctorReportSubmitPayload, +} from "@/components/doctor-report/doctor-report-dialog"; +import { useTranslations } from "@/lib/i18n/context"; + +type ExportFormat = "PDF" | "CSV" | "JSON"; + +/** + * 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 fetch(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(); + return ( +
+
+

+ {t("settings.sections.export.title")} +

+

+ {t("settings.sections.export.description")} +

+
+ + {/* Mobile: single column. Desktop (md+): 2-column grid. */} +
+ + + + + +
+
+ ); +} + +// ─────────────────────────── Card primitives ─────────────────────────── + +interface ExportCardShellProps { + testId: string; + icon: React.ComponentType<{ className?: string }>; + title: string; + description: string; + format: ExportFormat; + children?: React.ReactNode; + /** Footer slot — typically the "Download" button + status text. */ + footer: React.ReactNode; +} + +function ExportCardShell({ + testId, + icon: Icon, + title, + description, + format, + children, + footer, +}: ExportCardShellProps) { + return ( +
+
+
+ +

{title}

+
+ + {format} + +
+

{description}

+ {children &&
{children}
} +
{footer}
+
+ ); +} + +// ─────────────────────────── Doctor Report ─────────────────────────── + +function DoctorReportCard() { + const { t, locale } = useTranslations(); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [defaultPracticeName, setDefaultPracticeName] = useState( + null, + ); + const [error, setError] = useState(null); + + // Pre-fill the practice name from the user profile so the dialog opens + // ready-to-submit. Best-effort: if the request fails, the dialog still + // works without a pre-fill. + useEffect(() => { + let cancelled = false; + fetch("/api/auth/me", { credentials: "include" }) + .then((res) => (res.ok ? res.json() : null)) + .then((json) => { + if (cancelled || !json) return; + const name = json?.data?.lastReportPracticeName; + if (typeof name === "string") setDefaultPracticeName(name); + }) + .catch(() => { + // ignore — pre-fill is a UX nicety + }); + return () => { + cancelled = true; + }; + }, []); + + async function handleSubmit(payload: DoctorReportSubmitPayload) { + setBusy(true); + setError(null); + try { + const res = await fetch("/api/doctor-report", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + startDate: payload.startDate, + endDate: payload.endDate, + practiceName: payload.practiceName, + }), + }); + if (!res.ok) { + setError(`${res.status}`); + return; + } + const json = await res.json(); + const { generateDoctorReportPDF } = + await import("@/lib/doctor-report-pdf"); + const doc = generateDoctorReportPDF(json.data, { t, locale }); + const fileSlug = locale === "de" ? "gesundheitsbericht" : "health-report"; + doc.save(`${fileSlug}-${new Date().toISOString().slice(0, 10)}.pdf`); + if (payload.practiceName) setDefaultPracticeName(payload.practiceName); + setOpen(false); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + return ( + setOpen(true)} + disabled={busy} + > + {busy && } + {t("settings.sections.export.cards.doctorReport.action")} + + } + > + {error && ( +

+ {error} +

+ )} + +
+ ); +} + +// ─────────────────────────── 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 ( +
+
+ + setSince(e.target.value)} + max={until || undefined} + /> +
+
+ + setUntil(e.target.value)} + min={since || undefined} + /> +
+
+ ); +} + +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: React.ComponentType<{ className?: string }>; + 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 (err) { + setError(err instanceof Error ? err.message : String(err)); + } 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.csv${query}`, + `healthlog-medications-${stamp}.csv`, + ); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + return ( + + {busy ? ( + + ) : ( + + )} + {t("settings.sections.export.actions.download")} + + } + > + + + {error && ( +

+ {error} +

+ )} +
+ ); +} + +function MoodCsvCard() { + return ( + + ); +} + +function FullBackupCard() { + const { t } = useTranslations(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function handleDownload() { + setBusy(true); + setError(null); + try { + const stamp = new Date().toISOString().slice(0, 10); + await downloadFromUrl( + "/api/export/full-backup.json", + `healthlog-backup-${stamp}.json`, + ); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + return ( + + {busy ? ( + + ) : ( + + )} + {t("settings.sections.export.actions.download")} + + } + > + {error && ( +

+ {error} +

+ )} +
+ ); +}