feat(settings): gather medication settings into a Medikamente entry

Promote the Medikamente section from a hidden Layout-hub child to its own
standalone nav entry (always shown — medications is a CORE domain) and
gather the injection-site exclusions card here from the account profile,
so every medication-specific preference lives on one screen. The Layout
hub keeps only the dashboard + insights arrangement editors.

The low-stock card stays whole in Notifications: its runway config and the
low-stock reminder toggle are one tightly-coupled control writing a single
prefs row, so splitting only the config out would break the card.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 23:23:50 +02:00
1 parent 16bd43c08f
commit fce9ca8f1a
7 files changed
+89 -63

No files matched your search

@@ -20,17 +20,14 @@ function render(locale: "en" | "de" = "en") {
}
describe("<LayoutSection>", () => {
it("links to the personalization editors it still hosts", () => {
it("links to the arrangement editors it still hosts", () => {
const html = render();
for (const href of [
"/settings/dashboard",
"/settings/insights",
"/settings/medications",
]) {
for (const href of ["/settings/dashboard", "/settings/insights"]) {
expect(html).toContain(`href="${href}"`);
}
// v1.18.0 (S5) — Mood (Stimmung) graduated to its own module-gated
// nav entry and is no longer linked from the Layout hub.
// v1.18.0 (S5) — Medications (Medikamente) and Mood (Stimmung)
// graduated to their own nav entries and are no longer linked here.
expect(html).not.toContain('href="/settings/medications"');
expect(html).not.toContain('href="/settings/mood"');
});
@@ -38,11 +35,11 @@ describe("<LayoutSection>", () => {
const html = render();
expect(html).toContain("Dashboard");
expect(html).toContain("Insights");
expect(html).toContain("Medications");
});
it("resolves its copy in German too", () => {
const html = render("de");
expect(html).toContain("Medikamente");
expect(html).toContain("Layout");
expect(html).not.toContain("settings.sections.layout.");
});
});
@@ -36,7 +36,13 @@ vi.mock("@tanstack/react-query", () => ({
}
return { data: null, isLoading: false };
},
useQueryClient: () => ({ setQueryData: vi.fn(), getQueryData: vi.fn() }),
useQueryClient: () => ({
setQueryData: vi.fn(),
getQueryData: vi.fn(),
invalidateQueries: vi.fn(),
}),
// v1.18.0 (S5) — the gathered injection-sites card uses `useMutation`.
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("sonner", () => ({
@@ -47,6 +53,17 @@ vi.mock("@/lib/charts/reduced-motion", () => ({
prefersReducedMotion: () => true,
}));
// v1.18.0 (S5) — the section now reads `useAuth()` to gate the
// injection-sites card it gathered from the account profile.
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", role: "USER" },
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MedicationsSection } from "../medications-section";
@@ -111,6 +128,13 @@ describe("<MedicationsSection> — SSR smoke", () => {
expect(lastActive).toBeLessThan(html.indexOf("Amoxicillin"));
});
it("gathers the injection-sites card (moved from the account profile)", () => {
// v1.18.0 (S5) — injection-site exclusions are a medication setting and
// now live on this screen alongside the list view + order.
const html = render();
expect(html).toContain("Globally excluded injection sites");
});
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
@@ -177,11 +177,7 @@ describe("<SettingsShell>", () => {
// On `/settings/dashboard` (a Layout child reached through the hub) the
// Layout nav entry must read active even though Dashboard has no nav
// entry of its own. Both layouts emit the active link → two matches.
for (const child of [
"/settings/dashboard",
"/settings/insights",
"/settings/medications",
]) {
for (const child of ["/settings/dashboard", "/settings/insights"]) {
const html = renderShell({ pathname: child });
const layoutActive =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|<a\b[^>]*\bhref="\/settings\/layout"[^>]*\baria-current="page"/g;
@@ -193,8 +189,8 @@ describe("<SettingsShell>", () => {
// The page passes `active={section}` explicitly; a child slug must
// still resolve onto the Layout hub for nav highlighting.
const html = renderShell({
active: "medications",
pathname: "/settings/medications",
active: "dashboard",
pathname: "/settings/dashboard",
});
const layoutActive =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|<a\b[^>]*\bhref="\/settings\/layout"[^>]*\baria-current="page"/g;
@@ -238,15 +234,14 @@ describe("<SettingsShell>", () => {
// (single-line; the longer "Notification channels" wrapped). The
// `/notifications` inbox now shares the "Notifications" label.
expect(html).toContain("Notifications");
// v1.17.1 (F-2) — the personalization editors (Dashboard, Insights,
// Medications) are reached through one Layout hub entry; the hub
// itself is the single nav entry for that concept.
// v1.17.1 (F-2) — the dashboard + insights arrangement editors are
// reached through one Layout hub entry.
expect(html).toContain('href="/settings/layout"');
expect(html).toContain("Layout &amp; Personalization");
// Dashboard / Insights / Medications stay Layout-hub children.
expect(html).not.toContain('href="/settings/medications"');
// v1.18.0 (S5) — Mood (Stimmung) graduated to its own module-gated
// nav entry. With the default fail-open mock it renders.
// v1.18.0 (S5) — Medications (Medikamente, always shown — a CORE
// domain) and Mood (Stimmung, gated; fail-open mock shows it) are their
// own nav entries now.
expect(html).toContain('href="/settings/medications"');
expect(html).toContain('href="/settings/mood"');
// The ampersand is HTML-escaped by React SSR — assert on the encoded
// form so we don't accidentally match a parser that double-escapes.
@@ -276,12 +271,12 @@ describe("<SettingsShell>", () => {
// compound "Benachrichtigungs-Kanäle" wrapped). The `/notifications`
// inbox now shares the "Benachrichtigungen" label.
expect(html).toContain("Benachrichtigungen");
// v1.17.1 (F-2) — the personalization editors are reached through one
// v1.17.1 (F-2) — the arrangement editors are reached through one
// Layout hub entry ("Layout & Personalisierung").
expect(html).toContain('href="/settings/layout"');
expect(html).toContain("Layout &amp; Personalisierung");
expect(html).not.toContain('href="/settings/medications"');
// v1.18.0 (S5) — Mood (Stimmung) is its own nav entry now.
// v1.18.0 (S5) — Medications + Mood are their own nav entries now.
expect(html).toContain('href="/settings/medications"');
expect(html).toContain('href="/settings/mood"');
// v1.18.0 (S3) — Targets keeps its German nav entry ("Zielwerte");
// Sources folded into Integrations as a sub-tab.
+3 -6
View File
@@ -53,7 +53,6 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { TimezonePicker } from "@/components/settings/timezone-picker";
import { TimeFormatSelect } from "@/components/settings/time-format-select";
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
import { InjectionSitesCard } from "@/components/settings/injection-sites-card";
import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card";
import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
@@ -633,11 +632,9 @@ export function AccountSection() {
</form>
</div>
{/* Global injection-site exclusions live with the account
profile — a personal preference that applies app-wide. The
unit system moved into the profile form above, beside the
timezone dropdown. */}
<InjectionSitesCard isAuthenticated={isAuthenticated} />
{/* v1.18.0 (S5) — injection-site exclusions moved to the dedicated
Medikamente settings section, where every medication-specific
preference now lives. */}
{/* Cycle-tracking enable on-ramp — auto-on for female accounts, but this
lets any account opt in (or opt out) before the gated /cycle page is
+13 -16
View File
@@ -4,7 +4,6 @@ import Link from "next/link";
import {
ChevronRight,
LayoutDashboard,
Pill,
TrendingUp,
type LucideIcon,
} from "lucide-react";
@@ -15,13 +14,15 @@ import { useTranslations } from "@/lib/i18n/context";
/**
* v1.17.1 (F-2) — the one "Layout & Personalization" home.
*
* Before this section, "how my app is laid out" was scattered across four
* unrelated settings sections — Dashboard, Insights, Medications, Mood —
* with no shared framing, so the same concept (arrange / reorder / show-
* hide) read as four disconnected places. This section is the single
* front door: it presents the four personalization surfaces as one
* consistent set of cards and links to each editor, which all keep their
* own routes and mutation flows intact. One concept, one home.
* Before this section, "how my app is laid out" was scattered across
* several unrelated settings sections with no shared framing. This
* section is the single front door for the arrangement editors: it
* presents them as one consistent set of cards and links to each editor,
* which keep their own routes and mutation flows intact.
*
* v1.18.0 (S5) — Medications (Medikamente) and Mood (Stimmung) graduated
* to their own top-level nav entries, so the hub now hosts only the
* dashboard + insights arrangement editors.
*/
interface LayoutLink {
href: string;
@@ -43,14 +44,10 @@ const LAYOUT_LINKS: ReadonlyArray<LayoutLink> = [
titleKey: "settings.sections.layout.insights.title",
descriptionKey: "settings.sections.layout.insights.description",
},
{
href: "/settings/medications",
icon: Pill,
titleKey: "settings.sections.layout.medications.title",
descriptionKey: "settings.sections.layout.medications.description",
},
// v1.18.0 (S5) — Mood (Stimmung) graduated to its own top-level,
// module-gated nav entry, so it is no longer linked from the Layout hub.
// v1.18.0 (S5) — Medications (Medikamente) and Mood (Stimmung) graduated
// to their own top-level nav entries, so they are no longer linked from
// the Layout hub. Only the dashboard + insights arrangement editors
// remain here.
];
export function LayoutSection() {
+17 -10
View File
@@ -8,7 +8,8 @@ import {
type ReorderMedication,
} from "@/components/medications/medication-order-editor";
import { MedicationViewToggle } from "@/components/medications/medication-view-toggle";
import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
import { InjectionSitesCard } from "@/components/settings/injection-sites-card";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { applyMedicationOrder } from "@/lib/medications/medication-order";
@@ -16,17 +17,21 @@ 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:
* v1.16.10 — the "Medikamente" settings section. v1.18.0 (S5) promoted it
* from a Layout-hub child to its own standalone nav entry (always shown —
* medications is a CORE domain) and gathered the medication-specific
* preferences here. Hosts three 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.
* 3. Injection-site exclusions — moved here from the account profile so
* every medication-specific preference lives on one screen.
*
* Both write through the same `/api/medications/layout` contract the
* /medications page reads, so a save here repaints both list views.
* The list view + order both write through the same `/api/medications/layout`
* contract the /medications page reads, so a save here repaints both views.
*/
/** The slice of the medications list the order editor needs. */
@@ -39,6 +44,7 @@ interface MedicationListEntry {
export function MedicationsSection() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const { layout, isLayoutLoading, setView } = useMedicationListLayout();
const { data: medications, isLoading } = useQuery({
@@ -69,11 +75,7 @@ export function MedicationsSection() {
aria-labelledby="settings-section-medications-title"
className="space-y-6"
>
<header className="space-y-2">
<SettingsHubBackLink
href="/settings/layout"
labelKey="settings.sections.layout.backToHub"
/>
<header className="space-y-1">
<h1 id="settings-section-medications-title" className="sr-only">
{t("settings.sections.medications.title")}
</h1>
@@ -129,6 +131,11 @@ export function MedicationsSection() {
<MedicationOrderEditor medications={ordered} />
)}
</div>
{/* v1.18.0 (S5) — injection-site exclusions are a medication setting;
they moved here from the account profile so all medication-specific
preferences live in one place. */}
<InjectionSitesCard isAuthenticated={isAuthenticated} />
</section>
);
}
+12 -3
View File
@@ -26,6 +26,7 @@ import {
KeyRound,
LayoutDashboard,
Link2,
Pill,
Settings2,
SlidersHorizontal,
Smile,
@@ -121,6 +122,14 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
titleKey: "settings.sections.layout.title",
icon: LayoutDashboard,
},
// v1.18.0 (S5) — Medikamente: medication-specific settings (list view +
// order + injection sites). Medications is a CORE domain, so the entry is
// always shown. Was a hidden child of the Layout hub.
{
slug: "medications",
titleKey: "settings.sections.medications.title",
icon: Pill,
},
// v1.18.0 (S5) — Stimmung: the mood-tag management surface gets its own
// nav entry, shown only when the mood module is enabled. Was a hidden
// child of the Layout hub.
@@ -176,12 +185,12 @@ export interface SettingsShellProps {
// when the user is on one of them the Layout nav entry is the one that
// reads active.
//
// v1.18.0 (S5) — `mood` (Stimmung) graduated to its own nav entry, so it
// is no longer a Layout-hub child for highlighting purposes.
// v1.18.0 (S5) — `mood` (Stimmung) and `medications` (Medikamente)
// graduated to their own nav entries, so they are no longer Layout-hub
// children for highlighting purposes.
const LAYOUT_CHILD_SLUGS: ReadonlySet<string> = new Set([
"dashboard",
"insights",
"medications",
]);
/** Map a Layout child editor onto the Layout hub for nav highlighting. */