diff --git a/messages/de.json b/messages/de.json index 9e6204a33..27f2e6ce4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1285,10 +1285,18 @@ "lifecycle": "Lebenszyklus", "danger": "Gefahrenzone" }, - "csvImport": { - "title": "CSV-/JSON-Import", - "helper": "Vergangene Einnahmen aus einer Datei oder eingefügtem JSON importieren.", - "button": "CSV/JSON importieren" + "dataPortability": { + "import": { + "title": "Einnahmen importieren", + "helper": "Vergangene Einnahmen aus einer JSON-Datei oder eingefügtem JSON laden.", + "button": "JSON importieren" + }, + "export": { + "title": "Medikament exportieren", + "helper": "Dieses Medikament als CSV-Datei herunterladen.", + "includeIntake": "Einnahmeverlauf einschließen", + "button": "CSV exportieren" + } } }, "header": { diff --git a/messages/en.json b/messages/en.json index 287eb7e5b..236bf61e6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1285,10 +1285,18 @@ "lifecycle": "Lifecycle", "danger": "Danger zone" }, - "csvImport": { - "title": "CSV / JSON import", - "helper": "Import past intakes from a file or pasted JSON.", - "button": "Import CSV/JSON" + "dataPortability": { + "import": { + "title": "Import intakes", + "helper": "Load past intakes from a JSON file or pasted JSON.", + "button": "Import JSON" + }, + "export": { + "title": "Export medication", + "helper": "Download this medication as a CSV file.", + "includeIntake": "Include intake history", + "button": "Export CSV" + } } }, "header": { diff --git a/messages/es.json b/messages/es.json index ecd67d9cb..c23648866 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1285,10 +1285,18 @@ "lifecycle": "Ciclo de vida", "danger": "Zona de peligro" }, - "csvImport": { - "title": "Importar CSV/JSON", - "helper": "Importa tomas anteriores desde un archivo o JSON pegado.", - "button": "Importar CSV/JSON" + "dataPortability": { + "import": { + "title": "Importar tomas", + "helper": "Carga tomas anteriores desde un archivo JSON o JSON pegado.", + "button": "Importar JSON" + }, + "export": { + "title": "Exportar medicamento", + "helper": "Descarga este medicamento como archivo CSV.", + "includeIntake": "Incluir historial de tomas", + "button": "Exportar CSV" + } } }, "header": { diff --git a/messages/fr.json b/messages/fr.json index 4c9296dc8..a3289559a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1285,10 +1285,18 @@ "lifecycle": "Cycle de vie", "danger": "Zone de danger" }, - "csvImport": { - "title": "Import CSV/JSON", - "helper": "Importez les prises passées depuis un fichier ou du JSON collé.", - "button": "Importer CSV/JSON" + "dataPortability": { + "import": { + "title": "Importer les prises", + "helper": "Chargez les prises passées depuis un fichier JSON ou du JSON collé.", + "button": "Importer JSON" + }, + "export": { + "title": "Exporter le médicament", + "helper": "Téléchargez ce médicament au format CSV.", + "includeIntake": "Inclure l'historique des prises", + "button": "Exporter CSV" + } } }, "header": { diff --git a/messages/it.json b/messages/it.json index 1c1e2e244..e7368b5d0 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1285,10 +1285,18 @@ "lifecycle": "Ciclo di vita", "danger": "Zona pericolosa" }, - "csvImport": { - "title": "Importa CSV/JSON", - "helper": "Importa le assunzioni passate da un file o da JSON incollato.", - "button": "Importa CSV/JSON" + "dataPortability": { + "import": { + "title": "Importa assunzioni", + "helper": "Carica le assunzioni passate da un file JSON o da JSON incollato.", + "button": "Importa JSON" + }, + "export": { + "title": "Esporta farmaco", + "helper": "Scarica questo farmaco come file CSV.", + "includeIntake": "Includi cronologia delle assunzioni", + "button": "Esporta CSV" + } } }, "header": { diff --git a/messages/pl.json b/messages/pl.json index 375cf2fc1..e351b3e05 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1285,10 +1285,18 @@ "lifecycle": "Cykl życia", "danger": "Strefa zagrożenia" }, - "csvImport": { - "title": "Import CSV/JSON", - "helper": "Importuj wcześniejsze przyjęcia z pliku lub wklejonego JSON.", - "button": "Importuj CSV/JSON" + "dataPortability": { + "import": { + "title": "Importuj przyjęcia", + "helper": "Wczytaj wcześniejsze przyjęcia z pliku JSON lub wklejonego JSON.", + "button": "Importuj JSON" + }, + "export": { + "title": "Eksportuj lek", + "helper": "Pobierz ten lek jako plik CSV.", + "includeIntake": "Dołącz historię przyjęć", + "button": "Eksportuj CSV" + } } }, "header": { diff --git a/src/components/medications/__tests__/advanced-settings-sheet.test.tsx b/src/components/medications/__tests__/advanced-settings-sheet.test.tsx index 604bace52..1a83c95d6 100644 --- a/src/components/medications/__tests__/advanced-settings-sheet.test.tsx +++ b/src/components/medications/__tests__/advanced-settings-sheet.test.tsx @@ -8,9 +8,10 @@ * and echoes `contentWidth`. The bodies are mocked to ordered markers. * The test pins: * - the four groups render in the documented order; - * - the sheet opens at the `2xl` width token; + * - the sheet opens at the `4xl` width token; * - `onRequestPhaseSheet` threads through to the phases row; - * - `onOpenImport` wires the CSV-import button; + * - `onOpenImport` wires the import button (co-located with export); + * - the export control sits beside the import control; * - the lifecycle + danger bodies receive an `onAfterAction` closer; * - the sheet renders nothing when `open` is false. */ @@ -122,11 +123,11 @@ describe("AdvancedSettingsSheet (v1.7.0)", () => { expect(danger).toBeGreaterThan(lifecycle); }); - it("opens at the 2xl width token", () => { + it("opens at the 4xl width token", () => { const html = renderToStaticMarkup( , ); - expect(html).toContain('data-content-width="2xl"'); + expect(html).toContain('data-content-width="4xl"'); }); it("threads onRequestPhaseSheet through to the phases row", () => { @@ -138,14 +139,30 @@ describe("AdvancedSettingsSheet (v1.7.0)", () => { expect(html).toContain('data-has-phase-swap="yes"'); }); - it("wires the CSV/JSON import button", () => { + it("wires the import button", () => { const html = renderToStaticMarkup( {} })} />, ); - expect(html).toContain("advanced-csv-import-button"); - expect(html).toContain("medications.detail.advanced.csvImport.button"); + expect(html).toContain("advanced-import-button"); + expect(html).toContain( + "medications.detail.advanced.dataPortability.import.button", + ); + }); + + it("co-locates an export control beside the import control", () => { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain("advanced-export-button"); + expect(html).toContain( + "medications.detail.advanced.dataPortability.export.button", + ); + const importIdx = html.indexOf("advanced-import-block"); + const exportIdx = html.indexOf("advanced-export-block"); + expect(importIdx).toBeGreaterThan(-1); + expect(exportIdx).toBeGreaterThan(importIdx); }); it("passes an onAfterAction to the lifecycle and danger bodies so they can close the sheet", () => { diff --git a/src/components/medications/advanced-settings-sheet.tsx b/src/components/medications/advanced-settings-sheet.tsx index 2817ef966..d80ed5924 100644 --- a/src/components/medications/advanced-settings-sheet.tsx +++ b/src/components/medications/advanced-settings-sheet.tsx @@ -2,22 +2,29 @@ /** * v1.7.0 — Advanced settings sheet. + * v1.7.2 — widened to `4xl` and re-laid into a responsive two-column + * grid so a normal desktop viewport shows every group without forcing + * a long single-column scroll. * - * Widened to the `2xl` ResponsiveSheet token (672px desktop) and - * regrouped from three stacked section cards into four labelled groups - * under one consistent visual system (R-medui §5): + * Four labelled groups under one consistent visual system (R-medui §5): * - * - Data: real CSV/JSON import + external-API endpoint / token. + * - Data: intake Import + medications CSV Export (co-located) + + * external-API endpoint / token. * - Reminders: notifications switch + reminder window (grace). * - Lifecycle: pause/resume + end course + phases. * - Danger zone: purge intake history + delete medication. * + * Layout: on `lg+` the first three groups flow into a two-column grid — + * the Data group (tallest, with the export toggle + token snippets) + * holds the left column; Reminders + Lifecycle stack down the right. + * The Danger zone always spans the full width at the bottom, set off by + * its own destructive-tinted card, so an irreversible action never + * shares a column with a routine toggle. On `` (mounted by the page; opened via - * `onOpenImport`). + * toggles are switches wrapped in labels. * * The Phasen button is sibling-swapped, not nested: `onRequestPhaseSheet` * closes this sheet first and lets the parent open the phase sheet so @@ -25,13 +32,11 @@ * self-saves, so the footer slot stays empty. */ -import { Upload } from "lucide-react"; - -import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { SettingsGroup } from "@/components/medications/settings-group"; import { ApiTokensRow } from "@/components/medications/sections/api-tokens-row"; +import { DataPortabilityRow } from "@/components/medications/sections/data-portability-row"; import { NotificationsBody } from "@/components/medications/sections/notifications-section"; import { GraceRow, @@ -62,7 +67,7 @@ export interface AdvancedSettingsSheetProps { onRequestPhaseSheet?: () => void; /** * v1.7.0 — opens the page-owned `` from the Data - * group's CSV/JSON import button. + * group's import button. */ onOpenImport?: () => void; } @@ -90,89 +95,85 @@ export function AdvancedSettingsSheet({ open={open} onOpenChange={onOpenChange} title={t("medications.detail.advanced.title")} - contentWidth="2xl" + contentWidth="4xl" bodyClassName="gap-6" > -
- {/* DATA — CSV/JSON import + external API endpoint */} - -
-
-

- {t("medications.detail.advanced.csvImport.title")} -

-

- {t("medications.detail.advanced.csvImport.helper")} -

-
- -
- - - - -
- - {/* REMINDERS — notifications + reminder window */} - - - - - - - - - {/* LIFECYCLE — pause/resume + end course + phases */} - - onOpenChange(false)} - /> - - {isGlp1 && ( - <> - - - - )} - - {/* DANGER ZONE — purge + delete */} + + + + + + {/* LIFECYCLE — pause/resume + end course + phases */} + + onOpenChange(false)} + /> + + {isGlp1 && ( + <> + + + + )} + +
+ + + {/* DANGER ZONE — purge + delete. Full-width at the bottom, set + off by the destructive-tinted card inside the body so an + irreversible action never shares a column with a toggle. */} ` via + * `onOpenImport`. The dialog parses a JSON intake array. + * - Export: downloads `/api/export/medications` as CSV. The + * intake-history toggle mirrors the Settings → Export card, so an + * operator can grab the medication row plus its full intake log + * from the same place they imported it. + * + * The download helper spins up an off-DOM anchor — the same pattern the + * Settings export cards use — so the response stays a same-origin GET + * and never needs a new endpoint. + */ + +import { useState } from "react"; +import { Download, Loader2, Upload } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { useTranslations } from "@/lib/i18n/context"; + +export interface DataPortabilityRowProps { + medicationId: string; + /** Opens the page-owned ``. */ + onOpenImport?: () => void; +} + +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 DataPortabilityRow({ + medicationId, + onOpenImport, +}: DataPortabilityRowProps) { + const { t } = useTranslations(); + const [includeIntake, setIncludeIntake] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function handleExport() { + setBusy(true); + setError(null); + try { + const query = includeIntake ? "?intake=true" : "?intake=false"; + const stamp = new Date().toISOString().slice(0, 10); + await downloadFromUrl( + `/api/export/medications${query}`, + `healthlog-medications-${stamp}.csv`, + ); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + return ( +
+ {/* Import — JSON intake array */} +
+
+

+ {t("medications.detail.advanced.dataPortability.import.title")} +

+

+ {t("medications.detail.advanced.dataPortability.import.helper")} +

+
+ +
+ + {/* Export — medications CSV */} +
+
+

+ {t("medications.detail.advanced.dataPortability.export.title")} +

+

+ {t("medications.detail.advanced.dataPortability.export.helper")} +

+
+ + + {error && ( +

+ {error} +

+ )} +
+
+ ); +}