feat(medications): list customization moves into its own settings section

/settings/medications now hosts the view preference (cards or table)
and the manual-order editor inline, following the dashboard and
insights pattern. The page header's gear icon links there instead of
opening a dialog; the quick view toggle stays next to the add button.
The order editor keeps its grouped active/inactive semantics — a saved
order can never interleave the groups.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 19:35:23 +02:00
1 parent 5142823b7e
commit 02642a964e
17 files changed
+441 -180

No files matched your search

+7 -1
View File
@@ -1718,6 +1718,7 @@
"allowedSitesLabel": "Erlaubte Stellen",
"allowedSitesHint": "Alle ausgewählt lassen für keine Einschränkung. Global ausgeschlossene Stellen werden nie angeboten.",
"viewToggleLabel": "Ansicht",
"viewToggleHint": "Wähle, ob deine Medikamente als Karten oder als Tabelle angezeigt werden.",
"viewCards": "Kartenansicht",
"viewTable": "Tabellenansicht",
"viewSaveFailed": "Ansicht konnte nicht gespeichert werden",
@@ -1737,7 +1738,8 @@
"reorderTitle": "Reihenfolge anpassen",
"reorderDescription": "Bestimme die Reihenfolge deiner Medikamente – sie gilt für die Karten- und die Tabellenansicht.",
"reorderSaved": "Reihenfolge gespeichert",
"reorderSaveFailed": "Reihenfolge konnte nicht gespeichert werden"
"reorderSaveFailed": "Reihenfolge konnte nicht gespeichert werden",
"customize": "Medikamente anpassen"
},
"charts": {
"loadingLabel": "Diagramm wird geladen…",
@@ -3767,6 +3769,10 @@
"insights": {
"title": "Insights",
"description": "Reihenfolge der Übersicht und der Navigations-Pills."
},
"medications": {
"title": "Medikamente",
"description": "Listenansicht und Reihenfolge der Medikamente."
}
},
"profile": "Profil",
+7 -1
View File
@@ -1718,6 +1718,7 @@
"allowedSitesLabel": "Allowed sites",
"allowedSitesHint": "Leave all selected for no restriction. Sites you excluded globally are never offered.",
"viewToggleLabel": "View",
"viewToggleHint": "Choose whether your medications appear as cards or as a table.",
"viewCards": "Card view",
"viewTable": "Table view",
"viewSaveFailed": "View could not be saved",
@@ -1737,7 +1738,8 @@
"reorderTitle": "Adjust order",
"reorderDescription": "Set the order of your medications – it applies to both the card and the table view.",
"reorderSaved": "Order saved",
"reorderSaveFailed": "Order could not be saved"
"reorderSaveFailed": "Order could not be saved",
"customize": "Customize medications"
},
"charts": {
"loadingLabel": "Loading chart…",
@@ -3767,6 +3769,10 @@
"insights": {
"title": "Insights",
"description": "Overview and navigation-pill order."
},
"medications": {
"title": "Medications",
"description": "List view and medication order."
}
},
"profile": "Profile",
+7 -1
View File
@@ -1718,6 +1718,7 @@
"allowedSitesLabel": "Sitios permitidos",
"allowedSitesHint": "Deja todos seleccionados para no aplicar restricciones. Los sitios que excluiste globalmente nunca se ofrecen.",
"viewToggleLabel": "Vista",
"viewToggleHint": "Elige si tus medicamentos se muestran como tarjetas o como tabla.",
"viewCards": "Vista de tarjetas",
"viewTable": "Vista de tabla",
"viewSaveFailed": "No se pudo guardar la vista",
@@ -1737,7 +1738,8 @@
"reorderTitle": "Ajustar orden",
"reorderDescription": "Define el orden de tus medicamentos: se aplica a la vista de tarjetas y a la de tabla.",
"reorderSaved": "Orden guardado",
"reorderSaveFailed": "No se pudo guardar el orden"
"reorderSaveFailed": "No se pudo guardar el orden",
"customize": "Personalizar medicamentos"
},
"charts": {
"loadingLabel": "Cargando la gráfica…",
@@ -3767,6 +3769,10 @@
"insights": {
"title": "Análisis",
"description": "Orden de la vista general y de las pestañas de navegación."
},
"medications": {
"title": "Medicamentos",
"description": "Vista de lista y orden de los medicamentos."
}
},
"profile": "Perfil",
+7 -1
View File
@@ -1718,6 +1718,7 @@
"allowedSitesLabel": "Sites autorisés",
"allowedSitesHint": "Laissez tout sélectionné pour aucune restriction. Les sites exclus globalement ne sont jamais proposés.",
"viewToggleLabel": "Affichage",
"viewToggleHint": "Choisis si tes médicaments s'affichent sous forme de cartes ou de tableau.",
"viewCards": "Vue cartes",
"viewTable": "Vue tableau",
"viewSaveFailed": "L'affichage n'a pas pu être enregistré",
@@ -1737,7 +1738,8 @@
"reorderTitle": "Modifier l'ordre",
"reorderDescription": "Définis l'ordre de tes médicaments – il s'applique à la vue cartes et à la vue tableau.",
"reorderSaved": "Ordre enregistré",
"reorderSaveFailed": "L'ordre n'a pas pu être enregistré"
"reorderSaveFailed": "L'ordre n'a pas pu être enregistré",
"customize": "Personnaliser les médicaments"
},
"charts": {
"loadingLabel": "Chargement du graphique…",
@@ -3767,6 +3769,10 @@
"insights": {
"title": "Analyses",
"description": "Ordre de la vue d'ensemble et des onglets de navigation."
},
"medications": {
"title": "Médicaments",
"description": "Vue de la liste et ordre des médicaments."
}
},
"profile": "Profil",
+7 -1
View File
@@ -1718,6 +1718,7 @@
"allowedSitesLabel": "Siti consentiti",
"allowedSitesHint": "Lascia tutti selezionati per nessuna restrizione. I siti esclusi globalmente non vengono mai proposti.",
"viewToggleLabel": "Vista",
"viewToggleHint": "Scegli se i tuoi farmaci vengono mostrati come schede o come tabella.",
"viewCards": "Vista a schede",
"viewTable": "Vista tabella",
"viewSaveFailed": "Impossibile salvare la vista",
@@ -1737,7 +1738,8 @@
"reorderTitle": "Modifica ordine",
"reorderDescription": "Imposta l'ordine dei tuoi farmaci: vale per la vista a schede e per la tabella.",
"reorderSaved": "Ordine salvato",
"reorderSaveFailed": "Impossibile salvare l’ordine"
"reorderSaveFailed": "Impossibile salvare l’ordine",
"customize": "Personalizza i farmaci"
},
"charts": {
"loadingLabel": "Caricamento del grafico…",
@@ -3767,6 +3769,10 @@
"insights": {
"title": "Approfondimenti",
"description": "Ordine della panoramica e delle schede di navigazione."
},
"medications": {
"title": "Farmaci",
"description": "Vista elenco e ordine dei farmaci."
}
},
"profile": "Profilo",
+7 -1
View File
@@ -1718,6 +1718,7 @@
"allowedSitesLabel": "Dozwolone miejsca",
"allowedSitesHint": "Pozostaw wszystkie zaznaczone, aby nie stosować ograniczeń. Miejsca wykluczone globalnie nigdy nie są proponowane.",
"viewToggleLabel": "Widok",
"viewToggleHint": "Wybierz, czy leki mają być wyświetlane jako karty, czy jako tabela.",
"viewCards": "Widok kart",
"viewTable": "Widok tabeli",
"viewSaveFailed": "Nie udało się zapisać widoku",
@@ -1737,7 +1738,8 @@
"reorderTitle": "Zmień kolejność",
"reorderDescription": "Ustal kolejność swoich leków – obowiązuje w widoku kart i w tabeli.",
"reorderSaved": "Kolejność zapisana",
"reorderSaveFailed": "Nie udało się zapisać kolejności"
"reorderSaveFailed": "Nie udało się zapisać kolejności",
"customize": "Dostosuj leki"
},
"charts": {
"loadingLabel": "Ładowanie wykresu…",
@@ -3767,6 +3769,10 @@
"insights": {
"title": "Analizy",
"description": "Kolejność przeglądu i pigułek nawigacyjnych."
},
"medications": {
"title": "Leki",
"description": "Widok listy i kolejność leków."
}
},
"profile": "Profil",
@@ -33,9 +33,6 @@ vi.mock("@/components/medications/wizard/MedicationWizardDialog", () => ({
vi.mock("@/components/medications/log-intake-dialog", () => ({
LogIntakeDialog: () => null,
}));
vi.mock("@/components/medications/medication-reorder-dialog", () => ({
MedicationReorderDialog: () => null,
}));
import MedicationsPage from "@/app/medications/page";
import { I18nProvider } from "@/lib/i18n/context";
@@ -133,9 +130,15 @@ describe("/medications — view renders from the persisted preference", () => {
);
});
it("offers the manual-order editor from the header", () => {
it("links the header customize glyph to /settings/medications", () => {
// v1.16.10 — same entry-point logic as the dashboard and insights
// headers: the Settings2 ghost icon is a plain link to the page's
// settings section (view preference + manual order live there).
const html = renderPage({ version: 1, view: "cards", order: [] });
expect(html).toContain('aria-label="Adjust order"');
expect(html).toContain('href="/settings/medications"');
expect(html).toContain('aria-label="Customize medications"');
// The in-page order dialog is gone — no dialog trigger remains.
expect(html).not.toContain('aria-label="Adjust order"');
});
});
+24 -45
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
@@ -15,7 +16,6 @@ import {
MedicationTableSkeleton,
} from "@/components/medications/medication-table";
import { MedicationViewToggle } from "@/components/medications/medication-view-toggle";
import { MedicationReorderDialog } from "@/components/medications/medication-reorder-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
@@ -149,16 +149,13 @@ export default function MedicationsPage() {
// two paths: log an intake (incl. a backdated one) against an existing
// medication, or create a new medication (the existing wizard).
const [logIntakeOpen, setLogIntakeOpen] = useState(false);
// v1.16.10 — manual-order editor (shared by both views).
const [reorderOpen, setReorderOpen] = useState(false);
// v1.16.10 — the persisted list presentation: cards vs table plus the
// manual medication order, server-side per user
// (`GET`/`PUT /api/medications/layout`). The toggle writes
// optimistically; the reorder dialog saves explicitly.
const { layout, isLayoutLoading, setView } = useMedicationListLayout(
isAuthenticated,
);
// optimistically; the order editor lives at /settings/medications.
const { layout, isLayoutLoading, setView } =
useMedicationListLayout(isAuthenticated);
useEffect(() => {
if (shouldOpenFromUrl) {
@@ -268,25 +265,27 @@ export default function MedicationsPage() {
</div>
<div className="flex shrink-0 items-center gap-2">
{/* v1.16.10 — card ⇄ table view toggle, persisted server-side
per user; plus the manual-order editor shared by both
views. The editor opens behind the same Settings2 glyph
the dashboard and insights headers use for their
customize entry points, in the same slot (left of the
add button). It only earns its slot once there is
something to reorder. */}
per user. The Settings2 glyph next to it is the customize
shortcut to /settings/medications (view preference + the
manual-order editor), exactly like the dashboard and
insights headers link to their settings sections, in the
same slot (left of the add button) and with the same
responsive 44-px mobile tap floor. */}
<MedicationViewToggle view={layout.view} onChange={setView} />
{medsArray.length > 1 && (
<Button
variant="ghost"
size="icon"
className="size-11 sm:size-9"
onClick={() => setReorderOpen(true)}
aria-label={t("medications.reorderTitle")}
title={t("medications.reorderTitle")}
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/medications"
aria-label={t("medications.customize")}
title={t("medications.customize")}
>
<Settings2 className="h-4 w-4" />
</Button>
)}
<Settings2 className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
{/* v1.14.0 — the "Add" button is now a choice: log an intake
(incl. a backdated one) against an existing medication, or
create a new medication. v1.12.2 — match the dashboard Add
@@ -324,11 +323,7 @@ export default function MedicationsPage() {
// persisted view (table rows vs card grid) so resolving the data
// never swaps the footprint; while the view preference itself is
// still loading the default cards skeleton stands in.
<div
role="status"
aria-busy="true"
aria-label={t("medications.title")}
>
<div role="status" aria-busy="true" aria-label={t("medications.title")}>
{tableView && !isLayoutLoading ? (
<MedicationTableSkeleton />
) : (
@@ -462,22 +457,6 @@ export default function MedicationsPage() {
date+time that supports backdating. Submits to the existing
per-medication intake route (same slot upsert as a live "Taken"
tap) so the one-row-per-dose-slot invariant holds. */}
{/* v1.16.10 — manual-order editor. The list is the page's current
effective order (active block first, inactive after), so the
dialog opens showing exactly what both views render. */}
{reorderOpen && (
<MedicationReorderDialog
open={reorderOpen}
onOpenChange={setReorderOpen}
medications={[...activeMeds, ...inactiveMeds].map((m) => ({
id: m.id,
name: m.name,
dose: m.dose,
active: m.active,
}))}
/>
)}
{logIntakeOpen && (
<LogIntakeDialog
open={logIntakeOpen}
+2
View File
@@ -10,6 +10,7 @@ import { DashboardSection } from "@/components/settings/dashboard-section";
import { ExportSection } from "@/components/settings/export-section";
import { InsightsSection } from "@/components/settings/insights-section";
import { IntegrationsSection } from "@/components/settings/integrations-section";
import { MedicationsSection } from "@/components/settings/medications-section";
import { NotificationsSection } from "@/components/settings/notifications-section";
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
// v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are two
@@ -51,6 +52,7 @@ const SECTION_COMPONENTS: Record<
notifications: NotificationsSection,
dashboard: DashboardSection,
insights: InsightsSection,
medications: MedicationsSection,
thresholds: ThresholdsSection,
sources: () => <SourcesSection />,
api: ApiSection,
@@ -1,11 +1,14 @@
/**
* v1.16.10 — reorder dialog mirrors the page's grouping.
* v1.16.10 — the inline manual-order editor mirrors the page's grouping.
*
* Both /medications views pin inactive medications after the active
* block, so the dialog drafts TWO sections (Aktiv / Inaktiv) with a
* block, so the editor drafts TWO sections (Aktiv / Inaktiv) with a
* muted heading each: drag and arrows work within a section, and the
* saved order is active ids first, inactive ids after — by
* construction, not by validation.
* construction, not by validation. The editor renders inline in the
* Medikamente settings section (`/settings/medications`); it started
* life as a dialog on the /medications page and kept its contract when
* it moved.
*
* Project convention is SSR-only component tests (`renderToStaticMarkup`)
* plus pure-helper tests for the interactive plumbing an SSR mount
@@ -18,24 +21,6 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
// Radix Dialog portals at runtime; collapse to plain wrappers so the
// body materialises in static markup (same trick as the sibling suites).
vi.mock("@/components/ui/dialog", () => {
const Pass = ({ children }: { children?: React.ReactNode }) => (
<div>{children}</div>
);
return {
Dialog: ({ children }: { children?: React.ReactNode }) => (
<div data-slot="mock-dialog">{children}</div>
),
DialogContent: Pass,
DialogDescription: Pass,
DialogFooter: Pass,
DialogHeader: Pass,
DialogTitle: Pass,
};
});
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({}),
}));
@@ -48,8 +33,8 @@ vi.mock("@/lib/queries/use-medication-list-layout", () => ({
runSaveMedicationListOrder: vi.fn().mockResolvedValue(true),
}));
const { MedicationReorderDialog, buildSavedOrder, moveWithinSection } =
await import("../medication-reorder-dialog");
const { MedicationOrderEditor, buildSavedOrder, moveWithinSection } =
await import("../medication-order-editor");
function render(node: React.ReactNode): string {
return renderToStaticMarkup(
@@ -90,31 +75,23 @@ describe("buildSavedOrder — active ids always precede inactive ids", () => {
});
it("an inactive med can never land above an active one in the SAVED order", () => {
// Drive the dialog's own move primitive as far up as it allows and
// Drive the editor's own move primitive as far up as it allows and
// compose the save payload exactly like save() does.
let inactive = ["i1", "i2"];
inactive = moveWithinSection(inactive, "i2", -1); // i2 to the top of its group
inactive = moveWithinSection(inactive, "i2", -1); // edge — no-op
const saved = buildSavedOrder(["a1", "a2"], inactive);
expect(saved).toEqual(["a1", "a2", "i2", "i1"]);
const firstInactiveIdx = Math.min(
saved.indexOf("i1"),
saved.indexOf("i2"),
);
const firstInactiveIdx = Math.min(saved.indexOf("i1"), saved.indexOf("i2"));
const lastActiveIdx = Math.max(saved.indexOf("a1"), saved.indexOf("a2"));
expect(firstInactiveIdx).toBeGreaterThan(lastActiveIdx);
});
});
describe("<MedicationReorderDialog> — two-section rendering", () => {
describe("<MedicationOrderEditor> — two-section rendering", () => {
it("renders an Aktiv and an Inaktiv section with the rows grouped", () => {
const html = render(
<MedicationReorderDialog
open
onOpenChange={() => {}}
medications={MEDS}
/>,
);
const html = render(<MedicationOrderEditor medications={MEDS} />);
expect(html).toContain('data-slot="medication-order-editor"');
expect(html).toContain('data-slot="medication-reorder-section-active"');
expect(html).toContain('data-slot="medication-reorder-section-inactive"');
// Muted group headings.
@@ -134,24 +111,26 @@ describe("<MedicationReorderDialog> — two-section rendering", () => {
it("omits the Inaktiv section when every medication is active", () => {
const html = render(
<MedicationReorderDialog
open
onOpenChange={() => {}}
medications={MEDS.filter((m) => m.active)}
/>,
<MedicationOrderEditor medications={MEDS.filter((m) => m.active)} />,
);
expect(html).toContain('data-slot="medication-reorder-section-active"');
expect(html).not.toContain(
'data-slot="medication-reorder-section-inactive"',
);
});
it("hides the Save / Cancel pair until a draft exists (clean editor = no footer)", () => {
const html = render(<MedicationOrderEditor medications={MEDS} />);
expect(html).not.toContain(">Speichern<");
expect(html).not.toContain(">Abbrechen<");
});
});
describe("medication-reorder-dialog — structural guards (source)", () => {
describe("medication-order-editor — structural guards (source)", () => {
const src = readFileSync(
join(
process.cwd(),
"src/components/medications/medication-reorder-dialog.tsx",
"src/components/medications/medication-order-editor.tsx",
),
"utf8",
);
@@ -162,11 +141,11 @@ describe("medication-reorder-dialog — structural guards (source)", () => {
it("each section owns its own DndContext, so a drag cannot cross groups", () => {
// One <DndContext> inside the per-section component, none at the
// dialog level spanning both lists.
// editor level spanning both lists.
const sectionBody = src.slice(src.indexOf("function ReorderSection"));
expect(sectionBody).toContain("<DndContext");
expect(
src.slice(0, src.indexOf("function ReorderSection")),
).not.toContain("<DndContext");
expect(src.slice(0, src.indexOf("function ReorderSection"))).not.toContain(
"<DndContext",
);
});
});
@@ -20,14 +20,6 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { reorderById } from "@/lib/insights-layout-reorder";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
@@ -44,14 +36,21 @@ import { runSaveMedicationListOrder } from "@/lib/queries/use-medication-list-la
* mode rides (the standalone extraction of the dashboard's
* `reorderWidgets` contract), so the surfaces cannot drift.
*
* The dialog mirrors the page's grouping: an Aktiv section and an
* Hosted inline by the Medikamente settings section
* (`/settings/medications`), exactly like the dashboard layout editor
* lives under `/settings/dashboard` — the editor used to be a dialog on
* the /medications page; the page header now links here instead.
*
* The editor mirrors the page's grouping: an Aktiv section and an
* Inaktiv section (muted heading), each with its own drag context and
* arrow bounds, so a row can only move WITHIN its group — both views
* pin inactive medications after the active block, and an order that
* crossed the boundary could never render. Save persists the id order
* (active ids first, then inactive ids) through
* `PUT /api/medications/layout` (order-only, the stored view is
* preserved server-side).
* preserved server-side). The Save / Cancel pair only appears once a
* draft exists, following the dashboard layout section's draft state
* machine.
*/
export interface ReorderMedication {
@@ -61,9 +60,7 @@ export interface ReorderMedication {
active: boolean;
}
interface MedicationReorderDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
interface MedicationOrderEditorProps {
/** The full list in its current effective order (active block first). */
medications: ReorderMedication[];
}
@@ -100,28 +97,24 @@ export function moveWithinSection(
).map((r) => r.id);
}
export function MedicationReorderDialog({
open,
onOpenChange,
export function MedicationOrderEditor({
medications,
}: MedicationReorderDialogProps) {
}: MedicationOrderEditorProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const dragHintId = useId();
const [saving, setSaving] = useState(false);
// Per-section draft id order — null means "the order the page passed
// in". Created on the first move so an untouched dialog can cancel
// without churn.
// Per-section draft id order — null means "the order the host passed
// in". Created on the first move so an untouched editor renders the
// persisted order without churn; Cancel simply clears the drafts.
const [draftActive, setDraftActive] = useState<string[] | null>(null);
const [draftInactive, setDraftInactive] = useState<string[] | null>(null);
const byId = new Map(medications.map((m) => [m.id, m]));
const activeIds =
draftActive ??
medications.filter((m) => m.active).map((m) => m.id);
draftActive ?? medications.filter((m) => m.active).map((m) => m.id);
const inactiveIds =
draftInactive ??
medications.filter((m) => !m.active).map((m) => m.id);
draftInactive ?? medications.filter((m) => !m.active).map((m) => m.id);
const labels = {
moveUp: t("dashboard.moveUp"),
@@ -141,79 +134,73 @@ export function MedicationReorderDialog({
if (ok) {
setDraftActive(null);
setDraftInactive(null);
onOpenChange(false);
}
}
function close(next: boolean) {
if (!next) {
setDraftActive(null);
setDraftInactive(null);
}
onOpenChange(next);
function cancel() {
setDraftActive(null);
setDraftInactive(null);
}
// Presence of a draft implies dirty — no JSON comparison needed.
const dirty = draftActive !== null || draftInactive !== null;
return (
<Dialog open={open} onOpenChange={close}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("medications.reorderTitle")}</DialogTitle>
<DialogDescription>
{t("medications.reorderDescription")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4" data-slot="medication-order-editor">
{medications.length === 0 && (
<p className="text-muted-foreground text-sm">
{t("medications.emptyTitle")}
</p>
)}
{activeIds.length > 0 && (
<ReorderSection
heading={t("common.active")}
ids={activeIds}
byId={byId}
dragHintId={dragHintId}
disabled={saving}
labels={labels}
onChange={setDraftActive}
dataSlot="medication-reorder-section-active"
/>
)}
{inactiveIds.length > 0 && (
<ReorderSection
heading={t("common.inactive")}
ids={inactiveIds}
byId={byId}
dragHintId={dragHintId}
disabled={saving}
labels={labels}
onChange={setDraftInactive}
dataSlot="medication-reorder-section-inactive"
/>
)}
{activeIds.length + inactiveIds.length > 0 && (
<p id={dragHintId} className="text-muted-foreground sr-only">
{t("dashboard.dragHandleHint")}
</p>
)}
<div className="space-y-4">
{activeIds.length > 0 && (
<ReorderSection
heading={t("common.active")}
ids={activeIds}
byId={byId}
dragHintId={dragHintId}
disabled={saving}
labels={labels}
onChange={setDraftActive}
dataSlot="medication-reorder-section-active"
/>
)}
{inactiveIds.length > 0 && (
<ReorderSection
heading={t("common.inactive")}
ids={inactiveIds}
byId={byId}
dragHintId={dragHintId}
disabled={saving}
labels={labels}
onChange={setDraftInactive}
dataSlot="medication-reorder-section-inactive"
/>
)}
{activeIds.length + inactiveIds.length > 0 && (
<p id={dragHintId} className="text-muted-foreground sr-only">
{t("dashboard.dragHandleHint")}
</p>
)}
</div>
<DialogFooter>
{dirty && (
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
onClick={() => close(false)}
size="sm"
onClick={cancel}
disabled={saving}
>
{t("common.cancel")}
</Button>
<Button onClick={() => void save()} disabled={saving || !dirty}>
<Button size="sm" onClick={() => void save()} disabled={saving}>
{saving && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)}
</div>
);
}
@@ -0,0 +1,122 @@
/**
* v1.16.10 — the "Medikamente" settings section (`/settings/medications`).
*
* Hosts the /medications list customisation following the dashboard /
* insights pattern: the cards ⇄ table view preference (the shared header
* toggle, optimistic PUT) plus the manual-order editor INLINE (two
* grouped sections, explicit Save). SSR smoke assertions, matching the
* rest of the settings suite — the editor's interactive contract is
* pinned in `medication-order-editor.test.tsx`, the PUT side in
* `use-medication-list-layout.test.ts`.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { MedicationListLayout } from "@/lib/medication-list-layout";
const MEDS = [
{ id: "a1", name: "Ramipril", dose: "5 mg", active: true },
{ id: "a2", name: "Aspirin", dose: "100 mg", active: true },
{ id: "i1", name: "Amoxicillin", dose: "500 mg", active: false },
];
// Mutable holder so individual tests can inject a layout into the mocked
// `useQuery` without re-mocking the module.
const queryState: { layout: MedicationListLayout } = {
layout: { version: 1, view: "cards", order: [] },
};
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => {
if (Array.isArray(queryKey) && queryKey[0] === "medication-list-layout") {
return { data: queryState.layout, isLoading: false };
}
if (Array.isArray(queryKey) && queryKey[0] === "medications") {
return { data: MEDS, isLoading: false };
}
return { data: null, isLoading: false };
},
useQueryClient: () => ({ setQueryData: vi.fn(), getQueryData: vi.fn() }),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
vi.mock("@/lib/charts/reduced-motion", () => ({
prefersReducedMotion: () => true,
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MedicationsSection } from "../medications-section";
function render(locale: "en" | "de" = "en"): string {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<MedicationsSection />
</I18nProvider>,
);
}
beforeEach(() => {
queryState.layout = { version: 1, view: "cards", order: [] };
});
describe("<MedicationsSection> — SSR smoke", () => {
it("renders the section heading + description via i18n (no raw keys)", () => {
const html = render();
expect(html).toContain("settings-section-medications-title");
expect(html).toContain("List view and medication order.");
expect(html).not.toContain("settings.sections.");
expect(html).not.toContain("medications.view");
expect(html).not.toContain("medications.reorder");
});
it("renders the German copy end-to-end", () => {
const html = render("de");
expect(html).toContain("Listenansicht und Reihenfolge der Medikamente.");
expect(html).toContain("Reihenfolge anpassen");
expect(html).toContain(
"Wähle, ob deine Medikamente als Karten oder als Tabelle angezeigt werden.",
);
});
it("hosts the view toggle bound to the persisted layout (cards pressed)", () => {
const html = render();
expect(html).toMatch(
/data-slot="medications-view-cards"[^>]*aria-pressed="true"|aria-pressed="true"[^>]*data-slot="medications-view-cards"/,
);
expect(html).toMatch(
/data-slot="medications-view-table"[^>]*aria-pressed="false"|aria-pressed="false"[^>]*data-slot="medications-view-table"/,
);
});
it("reflects a persisted table view on the toggle", () => {
queryState.layout = { version: 1, view: "table", order: [] };
const html = render();
expect(html).toMatch(
/data-slot="medications-view-table"[^>]*aria-pressed="true"|aria-pressed="true"[^>]*data-slot="medications-view-table"/,
);
});
it("renders the order editor INLINE with active rows before inactive", () => {
const html = render();
expect(html).toContain('data-slot="medication-order-editor"');
expect(html).toContain('data-slot="medication-reorder-section-active"');
expect(html).toContain('data-slot="medication-reorder-section-inactive"');
const lastActive = Math.max(
html.indexOf("Ramipril"),
html.indexOf("Aspirin"),
);
expect(lastActive).toBeLessThan(html.indexOf("Amoxicillin"));
});
it("feeds the editor the saved order (active block re-sorted by layout.order)", () => {
// Alphabetical default would put Aspirin first; the saved order pins
// Ramipril to the top — the editor must open showing exactly what
// both /medications views render.
queryState.layout = { version: 1, view: "cards", order: ["a1", "a2"] };
const html = render();
expect(html.indexOf("Ramipril")).toBeLessThan(html.indexOf("Aspirin"));
});
});
@@ -50,12 +50,16 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// between `thresholds` (Targets) and `ai`.
// v1.15.18 — `insights` sits between `dashboard` and `thresholds`: the
// overview-arrange + pill-sort customise surface for `/insights`.
// v1.16.10 — `medications` sits between `insights` and `thresholds`:
// the view-preference + manual-order customise surface for
// `/medications`, reached from the page header's Settings2 glyph.
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
"account",
"integrations",
"notifications",
"dashboard",
"insights",
"medications",
"thresholds",
"sources",
"ai",
@@ -149,6 +153,10 @@ describe("<SettingsShell>", () => {
expect(html).toContain("Notifications");
expect(html).toContain("Dashboard");
expect(html).toContain("Insights");
// v1.16.10 — the /medications customise surface is its own nav entry
// between Insights and Targets.
expect(html).toContain('href="/settings/medications"');
expect(html).toContain("Medications");
// The ampersand is HTML-escaped by React SSR — assert on the encoded
// form so we don't accidentally match a parser that double-escapes.
expect(html).toContain("API &amp; Tokens");
@@ -181,6 +189,9 @@ describe("<SettingsShell>", () => {
// per-metric overrides moved out into their own "Persönliche Zielwerte"
// section, which is the new entry below the Dashboard one.
expect(html).toContain("Dashboard");
// v1.16.10 — the /medications customise surface ("Medikamente").
expect(html).toContain('href="/settings/medications"');
expect(html).toContain("Medikamente");
// v1.8.7.1 — Targets and Sources are two separate German nav
// entries again: "Zielwerte" and "Quellen".
expect(html).toContain("Zielwerte");
@@ -0,0 +1,129 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { LayoutGrid, Loader2, Pill } from "lucide-react";
import {
MedicationOrderEditor,
type ReorderMedication,
} from "@/components/medications/medication-order-editor";
import { MedicationViewToggle } from "@/components/medications/medication-view-toggle";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { applyMedicationOrder } from "@/lib/medications/medication-order";
import { queryKeys } from "@/lib/query-keys";
import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layout";
/**
* v1.16.10 — the "Medikamente" settings section (analogous to the
* Dashboard and Insights sections). Hosts two blocks:
* 1. The list VIEW preference — cards vs table, written optimistically
* through the same `PUT /api/medications/layout` the page header
* toggle uses (the toggle component is shared).
* 2. The manual ORDER editor — two grouped lists (Aktiv / Inaktiv)
* with drag + arrow reordering, flushed by an explicit Save,
* persisted as active-ids-then-inactive-ids on the same layout row.
*
* Both write through the same `/api/medications/layout` contract the
* /medications page reads, so a save here repaints both list views.
*/
/** The slice of the medications list the order editor needs. */
interface MedicationListEntry {
id: string;
name: string;
dose: string;
active: boolean;
}
export function MedicationsSection() {
const { t } = useTranslations();
const { layout, isLayoutLoading, setView } = useMedicationListLayout();
const { data: medications, isLoading } = useQuery({
queryKey: queryKeys.medications(),
queryFn: async () => {
return apiGet<MedicationListEntry[]>("/api/medications");
},
});
// Defensive against stale service-worker responses or any future API
// shape change: only map when we actually have an array. The editor
// receives the page's current effective order (active block first,
// inactive after) so it opens showing exactly what both views render.
const medsArray = Array.isArray(medications) ? medications : [];
const ordered: ReorderMedication[] = [
...applyMedicationOrder(
medsArray.filter((m) => m.active),
layout.order,
),
...applyMedicationOrder(
medsArray.filter((m) => !m.active),
layout.order,
),
].map((m) => ({ id: m.id, name: m.name, dose: m.dose, active: m.active }));
return (
<section
aria-labelledby="settings-section-medications-title"
className="space-y-6"
>
<header className="space-y-1">
<h1 id="settings-section-medications-title" className="sr-only">
{t("settings.sections.medications.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.medications.description")}
</p>
</header>
{/* View preference — cards vs table. The shared header toggle
writes optimistically, so there is no Save button here. */}
<div
id="medications-view"
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-4 sm:p-6"
>
<div className="flex items-center gap-2">
<LayoutGrid className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("medications.viewToggleLabel")}
</h2>
</div>
<div className="border-border bg-background/30 flex min-h-12 items-center justify-between gap-3 rounded-md border px-3 py-2">
<p className="text-muted-foreground min-w-0 text-xs">
{t("medications.viewToggleHint")}
</p>
{isLayoutLoading ? (
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<MedicationViewToggle view={layout.view} onChange={setView} />
)}
</div>
</div>
{/* Manual order — applies to both list views. */}
<div
id="medications-order"
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-4 sm:p-6"
>
<div className="flex items-center gap-2">
<Pill className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("medications.reorderTitle")}
</h2>
</div>
<p className="text-muted-foreground text-xs">
{t("medications.reorderDescription")}
</p>
{isLoading || isLayoutLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
{t("common.loading")}
</div>
) : (
<MedicationOrderEditor medications={ordered} />
)}
</div>
</section>
);
}
@@ -19,6 +19,7 @@ import {
Layers,
LayoutDashboard,
Link2,
Pill,
Settings2,
SlidersHorizontal,
Sparkles,
@@ -37,6 +38,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
notifications: Bell,
dashboard: LayoutDashboard,
insights: TrendingUp,
medications: Pill,
thresholds: SlidersHorizontal,
sources: Layers,
ai: Sparkles,
+5
View File
@@ -13,12 +13,17 @@
// `/api/auth/me/source-priority`) and reading them as one page made the
// surface long; splitting them back keeps each concern self-contained.
// Both slugs are served by the dynamic `[section]` route below.
// v1.16.10 — `medications` sits after `insights`: the customise surface
// for the /medications list (view preference + manual order), following
// the dashboard / insights pattern of a dedicated settings section
// reached from the page header's Settings2 glyph.
export const SETTINGS_SECTION_SLUGS = [
"account",
"integrations",
"notifications",
"dashboard",
"insights",
"medications",
"thresholds",
"sources",
"ai",
@@ -25,6 +25,7 @@ import {
Layers,
LayoutDashboard,
Link2,
Pill,
Settings2,
SlidersHorizontal,
Sparkles,
@@ -100,6 +101,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
titleKey: "settings.sections.insights.title",
icon: TrendingUp,
},
{
slug: "medications",
titleKey: "settings.sections.medications.title",
icon: Pill,
},
{
slug: "thresholds",
titleKey: "settings.sections.thresholds.title",