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

@@ -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",