feat(meds): widen advanced settings into a two-column layout with co-located export

Widen the medication advanced-settings dialog to the 4xl token and lay
the Data, Reminders and Lifecycle groups into a responsive two-column
grid on lg+ so a normal desktop viewport no longer forces a long
single-column scroll. The Danger zone keeps its destructive-tinted card
full-width at the bottom, separated from routine toggles.

Co-locate medications CSV export beside the intake import in the Data
group: a new DataPortabilityRow pairs the import button with an export
button that hits the existing /api/export/medications endpoint, with an
intake-history toggle mirroring the Settings export card. No new
endpoint.

Correct the import affordance copy to JSON — the dialog and API only
parse a JSON intake array, so the prior CSV/JSON label overstated the
accepted format.
This commit is contained in:
Marc-André Bombeck committed 2026-05-31 19:38:43 +02:00
1 parent 7802ce1e11
commit 91f23bae68
9 files changed
+349 -120

No files matched your search

+12 -4
View File
@@ -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": {
+12 -4
View File
@@ -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": {
+12 -4
View File
@@ -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": {
+12 -4
View File
@@ -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": {
+12 -4
View File
@@ -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": {
+12 -4
View File
@@ -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": {
@@ -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(
<AdvancedSettingsSheet {...baseProps(true)} />,
);
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(
<AdvancedSettingsSheet
{...baseProps(true, { onOpenImport: () => {} })}
/>,
);
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(
<AdvancedSettingsSheet {...baseProps(true)} />,
);
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", () => {
@@ -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 `<lg` everything stacks in
* the documented Data → Reminders → Lifecycle → Danger order.
*
* Button styling is consistent: reversible / neutral actions are
* `outline`, destructive actions are `destructive font-semibold`,
* toggles are switches wrapped in labels. The CSV import is promoted
* from a one-line stub to a real button that opens the existing
* `<IntakeImportDialog>` (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 `<IntakeImportDialog>` 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"
>
<div className="space-y-8" data-slot="advanced-settings-sheet-body">
{/* DATA — CSV/JSON import + external API endpoint */}
<SettingsGroup
label={t("medications.detail.advanced.group.data")}
dataSlot="advanced-group-data"
>
<div className="space-y-2" data-slot="advanced-csv-import-block">
<div className="space-y-1">
<p className="text-foreground text-sm font-medium">
{t("medications.detail.advanced.csvImport.title")}
</p>
<p className="text-muted-foreground text-xs">
{t("medications.detail.advanced.csvImport.helper")}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => onOpenImport?.()}
className="min-h-11 sm:min-h-9"
data-slot="advanced-csv-import-button"
<div className="space-y-6" data-slot="advanced-settings-sheet-body">
{/* Top region: Data + Reminders + Lifecycle in a two-column grid
on lg+, single column below. `items-start` keeps each group
top-aligned so the shorter right column never stretches. */}
<div className="grid items-start gap-6 lg:grid-cols-2">
{/* DATA — import + export + external API. Tallest group → left
column on lg+ so the right column's two shorter groups
balance against it. */}
<SettingsGroup
label={t("medications.detail.advanced.group.data")}
dataSlot="advanced-group-data"
>
<DataPortabilityRow
medicationId={medicationId}
onOpenImport={onOpenImport}
/>
<Separator />
<ApiTokensRow
medicationId={medicationId}
medicationName={medicationName}
/>
</SettingsGroup>
{/* Right column: Reminders + Lifecycle stacked. */}
<div className="space-y-6">
{/* REMINDERS — notifications + reminder window */}
<SettingsGroup
label={t("medications.detail.advanced.group.reminders")}
dataSlot="advanced-group-reminders"
>
<Upload aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.advanced.csvImport.button")}
</Button>
</div>
<Separator />
<ApiTokensRow
medicationId={medicationId}
medicationName={medicationName}
/>
</SettingsGroup>
{/* REMINDERS — notifications + reminder window */}
<SettingsGroup
label={t("medications.detail.advanced.group.reminders")}
dataSlot="advanced-group-reminders"
>
<NotificationsBody
medicationId={medicationId}
notificationsEnabled={notificationsEnabled}
/>
<Separator />
<GraceRow
medicationId={medicationId}
reminderGraceMinutes={reminderGraceMinutes}
/>
</SettingsGroup>
{/* LIFECYCLE — pause/resume + end course + phases */}
<SettingsGroup
label={t("medications.detail.advanced.group.lifecycle")}
dataSlot="advanced-group-lifecycle"
>
<LifecycleManageBody
medicationId={medicationId}
medicationName={medicationName}
active={active}
onAfterAction={() => onOpenChange(false)}
/>
{isGlp1 && (
<>
<Separator />
<PhasesRow
<NotificationsBody
medicationId={medicationId}
treatmentClass={treatmentClass}
startsOn={startsOn}
endsOn={endsOn}
onRequestPhaseSheet={onRequestPhaseSheet}
notificationsEnabled={notificationsEnabled}
/>
</>
)}
</SettingsGroup>
{/* DANGER ZONE — purge + delete */}
<Separator />
<GraceRow
medicationId={medicationId}
reminderGraceMinutes={reminderGraceMinutes}
/>
</SettingsGroup>
{/* LIFECYCLE — pause/resume + end course + phases */}
<SettingsGroup
label={t("medications.detail.advanced.group.lifecycle")}
dataSlot="advanced-group-lifecycle"
>
<LifecycleManageBody
medicationId={medicationId}
medicationName={medicationName}
active={active}
onAfterAction={() => onOpenChange(false)}
/>
{isGlp1 && (
<>
<Separator />
<PhasesRow
medicationId={medicationId}
treatmentClass={treatmentClass}
startsOn={startsOn}
endsOn={endsOn}
onRequestPhaseSheet={onRequestPhaseSheet}
/>
</>
)}
</SettingsGroup>
</div>
</div>
{/* 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. */}
<SettingsGroup
label={t("medications.detail.advanced.group.danger")}
dataSlot="advanced-group-danger"
@@ -0,0 +1,163 @@
"use client";
/**
* v1.7.2 — Data-portability row for the advanced-settings Data group.
*
* Co-locates intake Import and Export side by side so the two halves of
* the "move my data" flow share one visual block instead of import
* living alone in the sheet while export hides under /settings/export.
*
* - Import: opens the page-owned `<IntakeImportDialog>` 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 `<IntakeImportDialog>`. */
onOpenImport?: () => void;
}
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 DataPortabilityRow({
medicationId,
onOpenImport,
}: DataPortabilityRowProps) {
const { t } = useTranslations();
const [includeIntake, setIncludeIntake] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div
className="grid gap-4 sm:grid-cols-2"
data-slot="advanced-data-portability-row"
>
{/* Import — JSON intake array */}
<div
className="border-border bg-muted/30 flex flex-col gap-2 rounded-lg border p-3"
data-slot="advanced-import-block"
>
<div className="space-y-1">
<p className="text-foreground text-sm font-medium">
{t("medications.detail.advanced.dataPortability.import.title")}
</p>
<p className="text-muted-foreground text-xs">
{t("medications.detail.advanced.dataPortability.import.helper")}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => onOpenImport?.()}
className="mt-auto min-h-11 w-full sm:min-h-9"
data-slot="advanced-import-button"
>
<Upload aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.advanced.dataPortability.import.button")}
</Button>
</div>
{/* Export — medications CSV */}
<div
className="border-border bg-muted/30 flex flex-col gap-2 rounded-lg border p-3"
data-slot="advanced-export-block"
>
<div className="space-y-1">
<p className="text-foreground text-sm font-medium">
{t("medications.detail.advanced.dataPortability.export.title")}
</p>
<p className="text-muted-foreground text-xs">
{t("medications.detail.advanced.dataPortability.export.helper")}
</p>
</div>
<label
htmlFor={`advanced-export-include-intake-${medicationId}`}
className="flex min-h-11 cursor-pointer items-center gap-2 text-xs sm:min-h-8"
>
<Switch
id={`advanced-export-include-intake-${medicationId}`}
checked={includeIntake}
onCheckedChange={setIncludeIntake}
data-slot="advanced-export-include-intake"
/>
<span className="text-muted-foreground">
{t(
"medications.detail.advanced.dataPortability.export.includeIntake",
)}
</span>
</label>
<Button
variant="outline"
size="sm"
onClick={() => void handleExport()}
disabled={busy}
aria-busy={busy || undefined}
className="mt-auto min-h-11 w-full sm:min-h-9"
data-slot="advanced-export-button"
>
{busy ? (
<Loader2
aria-hidden="true"
className="h-4 w-4 animate-spin motion-reduce:animate-none"
/>
) : (
<Download aria-hidden="true" className="h-4 w-4" />
)}
{t("medications.detail.advanced.dataPortability.export.button")}
</Button>
{error && (
<p role="alert" className="text-destructive text-xs">
{error}
</p>
)}
</div>
</div>
);
}