mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(settings): export-section consolidates doctor-report, CSVs, JSON backup
Adds the consolidated `<ExportSection>` 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) <noreply@anthropic.com>
This commit is contained in:
1 parent
7fbfca672e
commit
621109cf20
4 files changed
+706
-2
No files matched your search
+36
-1
@@ -989,7 +989,42 @@
|
|||||||
},
|
},
|
||||||
"advanced": {
|
"advanced": {
|
||||||
"title": "Erweitert",
|
"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": {
|
"about": {
|
||||||
"title": "Über",
|
"title": "Über",
|
||||||
|
|||||||
+36
-1
@@ -989,7 +989,42 @@
|
|||||||
},
|
},
|
||||||
"advanced": {
|
"advanced": {
|
||||||
"title": "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": {
|
"about": {
|
||||||
"title": "About",
|
"title": "About",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("<ExportSection> — SSR smoke", () => {
|
||||||
|
it("renders the section heading + description", () => {
|
||||||
|
const html = render(<ExportSection />);
|
||||||
|
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(<ExportSection />);
|
||||||
|
// 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(<ExportSection />);
|
||||||
|
// 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(<ExportSection />);
|
||||||
|
// 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(<ExportSection />, "de");
|
||||||
|
expect(html).toContain("Export");
|
||||||
|
expect(html).not.toContain("settings.sections.export.");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 `<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. 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`, two-column grid 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<void> {
|
||||||
|
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 (
|
||||||
|
<section
|
||||||
|
aria-labelledby="settings-section-export-title"
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<header className="space-y-1">
|
||||||
|
<h1
|
||||||
|
id="settings-section-export-title"
|
||||||
|
className="text-2xl font-semibold tracking-tight"
|
||||||
|
>
|
||||||
|
{t("settings.sections.export.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t("settings.sections.export.description")}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Mobile: single column. Desktop (md+): 2-column grid. */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<DoctorReportCard />
|
||||||
|
<MeasurementsCsvCard />
|
||||||
|
<MedicationsCsvCard />
|
||||||
|
<MoodCsvCard />
|
||||||
|
<FullBackupCard />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────── 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 (
|
||||||
|
<div
|
||||||
|
data-testid={testId}
|
||||||
|
className="bg-card border-border flex h-full flex-col rounded-xl border p-5"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className="text-primary h-5 w-5 shrink-0" />
|
||||||
|
<h2 className="text-base font-semibold">{title}</h2>
|
||||||
|
</div>
|
||||||
|
<span className="border-border text-muted-foreground rounded-full border px-2 py-0.5 text-[11px] font-medium tracking-wide uppercase">
|
||||||
|
{format}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">{description}</p>
|
||||||
|
{children && <div className="mt-3 space-y-3">{children}</div>}
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-3">{footer}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────── Doctor Report ───────────────────────────
|
||||||
|
|
||||||
|
function DoctorReportCard() {
|
||||||
|
const { t, locale } = useTranslations();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [defaultPracticeName, setDefaultPracticeName] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<ExportCardShell
|
||||||
|
testId="export-card-doctor-report"
|
||||||
|
icon={Stethoscope}
|
||||||
|
title={t("settings.sections.export.cards.doctorReport.title")}
|
||||||
|
description={t("settings.sections.export.cards.doctorReport.description")}
|
||||||
|
format="PDF"
|
||||||
|
footer={
|
||||||
|
<Button
|
||||||
|
data-testid="export-action-doctor-report"
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
|
||||||
|
{t("settings.sections.export.cards.doctorReport.action")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-destructive text-xs">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<DoctorReportDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
defaultPracticeName={defaultPracticeName}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
/>
|
||||||
|
</ExportCardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────── 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>
|
||||||
|
<Input
|
||||||
|
id={`${idPrefix}-since`}
|
||||||
|
type="date"
|
||||||
|
value={since}
|
||||||
|
onChange={(e) => setSince(e.target.value)}
|
||||||
|
max={until || undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor={`${idPrefix}-until`} className="text-xs">
|
||||||
|
{t("settings.sections.export.filters.until")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`${idPrefix}-until`}
|
||||||
|
type="date"
|
||||||
|
value={until}
|
||||||
|
onChange={(e) => setUntil(e.target.value)}
|
||||||
|
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: 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<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 (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} 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"
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="mr-1 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-xs">
|
||||||
|
{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.csv"
|
||||||
|
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.csv${query}`,
|
||||||
|
`healthlog-medications-${stamp}.csv`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} 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"
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="mr-1 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 className="flex cursor-pointer items-center gap-2 text-xs">
|
||||||
|
<input
|
||||||
|
data-testid="export-medications-include-intake"
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeIntake}
|
||||||
|
onChange={(e) => setIncludeIntake(e.target.checked)}
|
||||||
|
className="border-border h-4 w-4 cursor-pointer rounded border"
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("settings.sections.export.cards.medicationsCsv.includeIntake")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-destructive text-xs">
|
||||||
|
{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.csv"
|
||||||
|
filenamePrefix="healthlog-mood"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FullBackupCard() {
|
||||||
|
const { t } = useTranslations();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<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"
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<FileText className="mr-1 h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{t("settings.sections.export.actions.download")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="text-destructive text-xs">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</ExportCardShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user