feat(settings): turn Appearance into a hub of per-module subpages

Appearance ("Darstellung") stacked every module's view and ordering
settings on one long page. Split it into a hub that lists the modules as
navigable rows, each opening its own /settings/layout/<module> subpage
with a back link — the per-module settings move verbatim, no restyling.
The module routes now redirect to those subpages instead of an in-page
anchor, and the in-app cogs link straight to them.
This commit is contained in:
Marc-André Bombeck committed 2026-06-30 18:53:50 +02:00
1 parent fa2e2c6ba2
commit a01c9d0435
20 files changed
+451 -205

No files matched your search

+22 -10
View File
@@ -144,34 +144,46 @@ const nextConfig: NextConfig = {
destination: "/settings/integrations#channels",
permanent: true,
},
// v1.25.7 — the per-module view/sort/order surfaces (Medikamente,
// Stimmung, Labor, Krankheit, Vorsorge) folded into "Darstellung"
// (`/settings/layout`) as inline anchored sections. Their standalone
// routes 301-redirect to the matching anchor so bookmarks, the PWA's
// cached navigation, and the per-module page-header cogs keep resolving.
// v1.25.11 (#148) — "Darstellung" (`/settings/layout`) is a HUB that lists
// each module's view/sort/order surface; every module now lives on its own
// subpage at `/settings/layout/<module>`. The legacy per-module routes
// 301-redirect to the matching SUBPAGE (no longer an in-page anchor) so
// bookmarks, the PWA's cached navigation, and the per-module page-header
// cogs keep resolving. `dashboard` + `insights` join the redirect set so
// all seven modules share one canonical URL shape.
{
source: "/settings/dashboard",
destination: "/settings/layout/dashboard",
permanent: true,
},
{
source: "/settings/insights",
destination: "/settings/layout/insights",
permanent: true,
},
{
source: "/settings/medications",
destination: "/settings/layout#medications",
destination: "/settings/layout/medications",
permanent: true,
},
{
source: "/settings/mood",
destination: "/settings/layout#mood",
destination: "/settings/layout/mood",
permanent: true,
},
{
source: "/settings/labs",
destination: "/settings/layout#labs",
destination: "/settings/layout/labs",
permanent: true,
},
{
source: "/settings/illness",
destination: "/settings/layout#illness",
destination: "/settings/layout/illness",
permanent: true,
},
{
source: "/settings/vorsorge",
destination: "/settings/layout#vorsorge",
destination: "/settings/layout/vorsorge",
permanent: true,
},
// v1.25.7 — clinician share links fold into the Gesundheitsakte section
+3 -1
View File
@@ -570,7 +570,9 @@ export default function InsightsPage() {
description={t("insights.editMode.emptyDescription")}
action={
<Button size="sm" asChild>
<Link href="/settings/insights">{t("insights.customize")}</Link>
<Link href="/settings/layout/insights">
{t("insights.customize")}
</Link>
</Button>
}
/>
+1 -1
View File
@@ -90,7 +90,7 @@ export default function LabsPage() {
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/labs"
href="/settings/layout/labs"
aria-label={t("labs.customize")}
title={t("labs.customize")}
>
@@ -123,12 +123,14 @@ describe("/medications — view renders from the persisted preference", () => {
expect(html).not.toContain('data-slot="medications-view-table"');
});
it("links the header customize glyph to /settings/medications", () => {
it("links the header customize glyph to /settings/layout/medications", () => {
// v1.16.11 — same entry-point logic as the dashboard and insights
// headers: one wrench ghost icon linking to the page's settings
// section (view preference + manual order live there).
// v1.25.11 (#148) — the per-module settings now live on the Appearance
// subpage `/settings/layout/medications`.
const html = renderPage({ version: 1, view: "cards", order: [] });
expect(html).toContain('href="/settings/medications"');
expect(html).toContain('href="/settings/layout/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"');
+1 -1
View File
@@ -370,7 +370,7 @@ export default function MedicationsPage() {
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/medications"
href="/settings/layout/medications"
aria-label={t("medications.customize")}
title={t("medications.customize")}
>
+1 -1
View File
@@ -90,7 +90,7 @@ export default function MoodPage() {
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/mood"
href="/settings/layout/mood"
aria-label={t("mood.customize")}
title={t("mood.customize")}
>
+85
View File
@@ -0,0 +1,85 @@
import { notFound } from "next/navigation";
import { LayoutModuleGate } from "@/components/settings/layout-module-gate";
import {
LAYOUT_GROUPS,
LAYOUT_GROUP_IDS,
isLayoutGroupId,
} from "@/components/settings/layout-groups";
import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link";
import { SettingsShell } from "@/components/settings/settings-shell";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { getServerTranslator } from "@/lib/i18n/server-translator";
/**
* v1.25.11 (#148) — per-module Appearance subpage.
*
* `/settings/layout/<module>` renders ONLY that module's section (the existing
* `*Section` component, verbatim) under the Appearance hub, with a
* "← Appearance" back-link. The hub at `/settings/layout` lists the modules and
* links here; the legacy `/settings/<module>` routes 301-redirect here
* (next.config.ts).
*
* Each subpage is pre-rendered for the known module ids via
* `generateStaticParams()`; `dynamicParams = false` 404s any other segment at
* routing time (re-checked with `notFound()` for defence-in-depth). The slug
* list is imported from the SERVER-safe `layout-groups` module so this server
* component never pulls runtime values across a client boundary.
*/
export const dynamicParams = false;
export function generateStaticParams() {
return LAYOUT_GROUP_IDS.map((module) => ({ module }));
}
interface PageProps {
// Next.js 16 made route `params` an async Promise. We `await` it before use.
params: Promise<{ module: string }>;
}
export default async function SettingsLayoutModulePage({ params }: PageProps) {
const { module } = await params;
if (!isLayoutGroupId(module)) {
notFound();
}
const group = LAYOUT_GROUPS.find((entry) => entry.id === module);
if (!group) {
notFound();
}
// Heading lives in the shell (its own grid row spanning only the content
// column). The title is the MODULE's title, resolved server-side from the
// cookie locale (mirroring the onboarding pages) so the shell receives a
// ready string — no client re-translation, so server HTML and the hydration
// render carry the identical heading text.
const locale = await resolveServerLocale();
const { t } = getServerTranslator(locale);
const Body = group.Body;
const headingId = `settings-section-${module}-title`;
return (
<SettingsShell
active="layout"
heading={{
title: t(group.titleKey),
subtitle: t(group.descriptionKey),
headingId,
topSlot: (
<SettingsHubBackLink
href="/settings/layout"
labelKey="settings.sections.layout.backToHub"
/>
),
}}
>
<section aria-labelledby={headingId} className="space-y-6">
<LayoutModuleGate moduleGate={group.moduleGate}>
<Body />
</LayoutModuleGate>
</section>
</SettingsShell>
);
}
@@ -110,7 +110,7 @@ export function DashboardHeader({
data-testid="dashboard-customize-shortcut"
>
<Link
href="/settings/dashboard"
href="/settings/layout/dashboard"
aria-label={t("dashboard.customizeDashboard")}
title={t("dashboard.customizeDashboard")}
>
+1 -1
View File
@@ -401,7 +401,7 @@ export function IllnessView() {
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/illness"
href="/settings/layout/illness"
aria-label={t("illness.customize")}
title={t("illness.customize")}
>
@@ -49,7 +49,9 @@ describe("<InsightsEditMode> — detail-page manager retired (v1.15.20)", () =>
it("links to the settings pill section instead", () => {
const html = renderEditMode();
expect(html).toContain('data-slot="insights-edit-manage-link"');
expect(html).toContain('href="/settings/insights#insights-pill-order"');
expect(html).toContain(
'href="/settings/layout/insights#insights-pill-order"',
);
expect(html).toContain("Manage pills &amp; detail pages in Settings");
});
@@ -279,7 +279,7 @@ export function InsightsEditMode({
the Vitals row duplicated it. Keep a quiet pointer instead. */}
<p className="text-muted-foreground text-xs">
<Link
href="/settings/insights#insights-pill-order"
href="/settings/layout/insights#insights-pill-order"
data-slot="insights-edit-manage-link"
className="hover:text-foreground focus-visible:ring-ring rounded underline underline-offset-2 focus-visible:ring-2 focus-visible:outline-none"
>
@@ -882,7 +882,7 @@ function InsightsTabStripImpl({
point. */}
{onRegenerate && (
<Link
href="/settings/insights"
href="/settings/layout/insights"
aria-label={customizeLabel}
title={customizeLabel}
data-slot="insights-tab-strip-customize"
@@ -377,7 +377,7 @@ export function VorsorgeSection({
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/vorsorge"
href="/settings/layout/vorsorge"
aria-label={t("measurementReminders.customize")}
title={t("measurementReminders.customize")}
>
@@ -0,0 +1,83 @@
/**
* v1.25.11 (#148) — the shared Appearance group registry.
*
* `layout-groups` is the single source of truth consumed by BOTH the client
* hub (`layout-section.tsx`) and the server subpage route
* (`app/settings/layout/[module]/page.tsx`, via `LAYOUT_GROUP_IDS` in
* `generateStaticParams()`). These tests pin the param → section mapping: the
* id list (the statically-generated subpage params), the guard, the per-module
* gate expectations, and that each group resolves a distinct section body.
*/
import { describe, expect, it, vi } from "vitest";
// Stub the section components at the import boundary — each has its own test;
// here we only assert the registry's metadata + identity mapping, and we keep
// the heavy section trees out of the unit import graph.
vi.mock("../dashboard-section", () => ({ DashboardSection: () => null }));
vi.mock("../insights-section", () => ({ InsightsSection: () => null }));
vi.mock("../medications-section", () => ({ MedicationsSection: () => null }));
vi.mock("../mood-section", () => ({ MoodSection: () => null }));
vi.mock("../labs-section", () => ({ LabsSection: () => null }));
vi.mock("../illness-section", () => ({ IllnessSection: () => null }));
vi.mock("../vorsorge-section", () => ({ VorsorgeSection: () => null }));
import {
LAYOUT_GROUPS,
LAYOUT_GROUP_IDS,
isLayoutGroupId,
} from "../layout-groups";
describe("layout-groups registry", () => {
it("exposes the seven Appearance modules as statically-generated subpage params", () => {
expect(LAYOUT_GROUP_IDS).toEqual([
"dashboard",
"insights",
"medications",
"mood",
"labs",
"illness",
"vorsorge",
]);
// generateStaticParams() shape.
expect(LAYOUT_GROUP_IDS.map((module) => ({ module }))).toEqual([
{ module: "dashboard" },
{ module: "insights" },
{ module: "medications" },
{ module: "mood" },
{ module: "labs" },
{ module: "illness" },
{ module: "vorsorge" },
]);
});
it("guards unknown segments", () => {
expect(isLayoutGroupId("medications")).toBe(true);
expect(isLayoutGroupId("vorsorge")).toBe(true);
expect(isLayoutGroupId("nope")).toBe(false);
expect(isLayoutGroupId("")).toBe(false);
});
it("gates the toggleable tracking modules and leaves the rest always-on", () => {
const gate = (id: string) =>
LAYOUT_GROUPS.find((group) => group.id === id)?.moduleGate;
expect(gate("medications")).toBe("medications");
expect(gate("mood")).toBe("mood");
expect(gate("labs")).toBe("labs");
expect(gate("illness")).toBe("illness");
// dashboard / insights / vorsorge are never gated.
expect(gate("dashboard")).toBeUndefined();
expect(gate("insights")).toBeUndefined();
expect(gate("vorsorge")).toBeUndefined();
});
it("maps every id to a distinct section body and the canonical i18n keys", () => {
const bodies = new Set(LAYOUT_GROUPS.map((group) => group.Body));
expect(bodies.size).toBe(LAYOUT_GROUPS.length);
for (const group of LAYOUT_GROUPS) {
expect(group.titleKey).toBe(`settings.sections.layout.${group.id}.title`);
expect(group.descriptionKey).toBe(
`settings.sections.layout.${group.id}.description`,
);
}
});
});
@@ -1,13 +1,14 @@
/**
* v1.25.7 — the "Appearance" home (slug stays `layout`).
* v1.25.11 (#148) — the "Appearance" hub (slug stays `layout`).
*
* The hub stopped being a link list: it now composes the dashboard, insights,
* and every tracking module's settings inline as stacked, anchored sections.
* Each module section keeps its module-gate (fail-open `!== false`), so a
* disabled module's section does not render. These tests pin the composition
* contract — anchors, headings, and gating — and mock the child section
* components (each has its own dedicated test) so the render stays a pure
* composition smoke.
* The hub is a navigable index: it lists each module as a clickable row
* (`<a href="/settings/layout/<id>">` with the localized title + description +
* a chevron) and links to the module's own subpage. NOTHING is stacked inline.
* These tests pin the hub contract — the rows link to the right subpages, the
* module gate hides a disabled module's row, the never-gated rows always show,
* and the titles resolve through i18n. The child section components are NOT
* rendered by the hub, so they need no mocking here (each has its own test and
* its own subpage).
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -23,28 +24,12 @@ vi.mock("@/hooks/use-auth", () => ({
}),
}));
// Each composed surface has its own test; stub them at the import boundary so
// this test exercises only the LayoutSection composition (anchors + gating).
vi.mock("@/components/settings/dashboard-section", () => ({
DashboardSection: () => <div data-testid="body-dashboard" />,
}));
vi.mock("@/components/settings/insights-section", () => ({
InsightsSection: () => <div data-testid="body-insights" />,
}));
vi.mock("@/components/settings/medications-section", () => ({
MedicationsSection: () => <div data-testid="body-medications" />,
}));
vi.mock("@/components/settings/mood-section", () => ({
MoodSection: () => <div data-testid="body-mood" />,
}));
vi.mock("@/components/settings/labs-section", () => ({
LabsSection: () => <div data-testid="body-labs" />,
}));
vi.mock("@/components/settings/illness-section", () => ({
IllnessSection: () => <div data-testid="body-illness" />,
}));
vi.mock("@/components/settings/vorsorge-section", () => ({
VorsorgeSection: () => <div data-testid="body-vorsorge" />,
// The hub renders post-mount fail-open gating via `useMounted()`. Under SSR
// (`renderToStaticMarkup`) `useMounted()` returns `false`, which would render
// EVERY row regardless of the gate map. Force it to the hydrated (`true`)
// snapshot so these tests exercise the real module filter.
vi.mock("@/hooks/use-mounted", () => ({
useMounted: () => true,
}));
import { I18nProvider } from "@/lib/i18n/context";
@@ -59,8 +44,8 @@ function render(modules?: Record<string, boolean>, locale: "en" | "de" = "en") {
);
}
describe("<LayoutSection>", () => {
it("stacks every surface inline with a stable anchor id (fail-open default)", () => {
describe("<LayoutSection> hub", () => {
it("lists every module as a clickable row linking to its subpage", () => {
const html = render(undefined);
for (const id of [
"dashboard",
@@ -71,45 +56,40 @@ describe("<LayoutSection>", () => {
"illness",
"vorsorge",
]) {
expect(html).toContain(`id="${id}"`);
expect(html).toContain(`data-testid="body-${id}"`);
expect(html).toContain(`href="/settings/layout/${id}"`);
}
// It is no longer a link hub — no `/settings/*` cards.
expect(html).not.toContain('href="/settings/');
});
it("hides a module's section when that module is disabled", () => {
it("hides a module's row when that module is disabled", () => {
const html = render({ medications: false, labs: false });
expect(html).not.toContain('data-testid="body-medications"');
expect(html).not.toContain('id="medications"');
expect(html).not.toContain('data-testid="body-labs"');
expect(html).not.toContain('id="labs"');
expect(html).not.toContain('href="/settings/layout/medications"');
expect(html).not.toContain('href="/settings/layout/labs"');
// Other modules unaffected; Vorsorge is never gated.
expect(html).toContain('data-testid="body-mood"');
expect(html).toContain('data-testid="body-vorsorge"');
expect(html).toContain('href="/settings/layout/mood"');
expect(html).toContain('href="/settings/layout/vorsorge"');
});
it("always renders dashboard, insights, and vorsorge (no module gate)", () => {
it("always lists dashboard, insights, and vorsorge (no module gate)", () => {
const html = render({
medications: false,
mood: false,
labs: false,
illness: false,
});
expect(html).toContain('data-testid="body-dashboard"');
expect(html).toContain('data-testid="body-insights"');
expect(html).toContain('data-testid="body-vorsorge"');
expect(html).not.toContain('data-testid="body-mood"');
expect(html).toContain('href="/settings/layout/dashboard"');
expect(html).toContain('href="/settings/layout/insights"');
expect(html).toContain('href="/settings/layout/vorsorge"');
expect(html).not.toContain('href="/settings/layout/mood"');
});
it("resolves its section headings via i18n (no raw keys leak)", () => {
it("resolves its row titles via i18n (no raw keys leak)", () => {
const html = render();
expect(html).toContain("Medications");
expect(html).toContain("Labs");
expect(html).not.toContain("settings.sections.layout.");
});
it("resolves the headings in German too", () => {
it("resolves the titles in German too", () => {
const html = render(undefined, "de");
expect(html).toContain("Medikamente");
expect(html).toContain("Labor");
@@ -28,8 +28,6 @@ const SETTINGS_DIR = join(ROOT, "src/components/settings");
// Files allowed to retain a hand-rolled `<h2 text-lg>` header.
const ALLOWLIST = new Set<string>([
// TODO #148: rebuilt into hub+subpages, will use SettingsCardHeader then
"layout-section.tsx",
// allowed: collapsible-toggle header, button-wrapped, typography already canonical
"security-activity-card.tsx",
// allowed: collapsible-toggle header, button-wrapped, typography already canonical
+109
View File
@@ -0,0 +1,109 @@
import type { JSX } from "react";
import type { ModuleKey } from "@/lib/modules/registry";
import { DashboardSection } from "./dashboard-section";
import { InsightsSection } from "./insights-section";
import { MedicationsSection } from "./medications-section";
import { MoodSection } from "./mood-section";
import { LabsSection } from "./labs-section";
import { IllnessSection } from "./illness-section";
import { VorsorgeSection } from "./vorsorge-section";
/**
* v1.25.11 (#148) — single source of truth for the "Appearance" (slug
* `layout`) hub + its per-module subpages.
*
* "How my app looks and is arranged" was scattered across several unrelated
* settings sections. The `layout` slug is the one front door. v1.25.7 stacked
* every module's view/sort surface inline on a single page; #148 splits that
* back into a HUB → SUBPAGE model: `/settings/layout` lists the modules as
* clickable rows, and each row opens `/settings/layout/<id>`, which renders
* ONLY that module's section (the same existing section component, verbatim)
* with a "← Appearance" back-link.
*
* This module is intentionally NOT `"use client"`: the client hub
* (`layout-section.tsx`) AND the server subpage route
* (`app/settings/layout/[module]/page.tsx`, which reads `LAYOUT_GROUP_IDS` in
* `generateStaticParams()`) both consume it, so its array values must stay
* usable on the server. It holds references to the `"use client"` section
* components, which a server component renders as ordinary client boundaries.
*
* Each module section keeps its module-gate: a disabled module's row / subpage
* does not render, read from the resolved `useAuth().user.modules` map (the
* same map the nav, the Modules hub, and the Insights pills gate off). The gate
* fails OPEN (`!== false`) so a stale `/me` payload never blanks an entry.
* Vorsorge (preventive-care reminders) is not a toggleable module, so it always
* shows.
*/
export interface LayoutGroup {
/** Stable id — the subpage path segment (`/settings/layout/<id>`). */
id: string;
/** i18n key under `settings.sections.layout.<slug>.title`. */
titleKey: string;
/** i18n key under `settings.sections.layout.<slug>.description`. */
descriptionKey: string;
Body: () => JSX.Element | null;
/**
* When set, the row / subpage renders only while the module is enabled
* (fail-open, `!== false`). Omitted = always shown.
*/
moduleGate?: ModuleKey;
}
export const LAYOUT_GROUPS: ReadonlyArray<LayoutGroup> = [
{
id: "dashboard",
titleKey: "settings.sections.layout.dashboard.title",
descriptionKey: "settings.sections.layout.dashboard.description",
Body: DashboardSection,
},
{
id: "insights",
titleKey: "settings.sections.layout.insights.title",
descriptionKey: "settings.sections.layout.insights.description",
Body: InsightsSection,
},
{
id: "medications",
titleKey: "settings.sections.layout.medications.title",
descriptionKey: "settings.sections.layout.medications.description",
Body: MedicationsSection,
moduleGate: "medications",
},
{
id: "mood",
titleKey: "settings.sections.layout.mood.title",
descriptionKey: "settings.sections.layout.mood.description",
Body: MoodSection,
moduleGate: "mood",
},
{
id: "labs",
titleKey: "settings.sections.layout.labs.title",
descriptionKey: "settings.sections.layout.labs.description",
Body: LabsSection,
moduleGate: "labs",
},
{
id: "illness",
titleKey: "settings.sections.layout.illness.title",
descriptionKey: "settings.sections.layout.illness.description",
Body: IllnessSection,
moduleGate: "illness",
},
{
id: "vorsorge",
titleKey: "settings.sections.layout.vorsorge.title",
descriptionKey: "settings.sections.layout.vorsorge.description",
Body: VorsorgeSection,
},
];
/** Ordered list of the subpage path segments — server-safe for `generateStaticParams()`. */
export const LAYOUT_GROUP_IDS: readonly string[] = LAYOUT_GROUPS.map(
(group) => group.id,
);
export function isLayoutGroupId(value: string): boolean {
return LAYOUT_GROUP_IDS.includes(value);
}
@@ -0,0 +1,52 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import type { ModuleKey } from "@/lib/modules/registry";
/**
* v1.25.11 (#148) — client gate for an Appearance subpage
* (`/settings/layout/<module>`).
*
* A module's subpage may be reached by direct URL even when the user has the
* module turned off. This wrapper fails OPEN: it renders the section unless the
* resolved `useAuth().user.modules` map has the gate key explicitly `false`,
* in which case it bounces back to the hub (`/settings/layout`).
*
* The gate is hydration-stable: `useMounted()` returns `false` during SSR AND
* the first client paint, so the first render ALWAYS shows the section
* (matching the server HTML); the real check applies once, after hydration, as
* an ordinary client update. The redirect is a client-only effect, so it never
* diverges the initial render and cannot trigger a React #418 mismatch.
*
* Groups with no `moduleGate` (dashboard / insights / vorsorge) pass
* `moduleGate={undefined}` and always render.
*/
export function LayoutModuleGate({
moduleGate,
children,
}: {
moduleGate?: ModuleKey;
children: React.ReactNode;
}) {
const hydrated = useMounted();
const { user } = useAuth();
const router = useRouter();
const disabled =
hydrated &&
moduleGate !== undefined &&
user?.modules?.[moduleGate] === false;
React.useEffect(() => {
if (disabled) {
router.replace("/settings/layout");
}
}, [disabled, router]);
if (disabled) return null;
return <>{children}</>;
}
+45 -126
View File
@@ -1,146 +1,65 @@
"use client";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useTranslations } from "@/lib/i18n/context";
import type { ModuleKey } from "@/lib/modules/registry";
import { DashboardSection } from "./dashboard-section";
import { InsightsSection } from "./insights-section";
import { MedicationsSection } from "./medications-section";
import { MoodSection } from "./mood-section";
import { LabsSection } from "./labs-section";
import { IllnessSection } from "./illness-section";
import { VorsorgeSection } from "./vorsorge-section";
import { SettingsCard } from "./settings-card";
import { LAYOUT_GROUPS } from "./layout-groups";
/**
* v1.17.1 (F-2) — the "Appearance" home (slug stays `layout`).
* v1.25.11 (#148) — the "Appearance" hub (slug stays `layout`).
*
* "How my app looks and is arranged" was scattered across several unrelated
* settings sections with no shared framing. This section is the single front
* door for every view/arrangement surface.
* The hub is a navigable index, NOT a stacked composition: it lists each
* module as a clickable row (title + description + chevron) that opens the
* module's own subpage at `/settings/layout/<id>`. Nothing is stacked inline;
* the per-module view/sort surfaces live on their subpages so the hub reads as
* a short, scannable directory. The visible page heading + subtitle
* ("Appearance" / "Personalize how …") are painted by `SettingsShell` from the
* `layout` slug; this body renders only the row list.
*
* v1.25.7 — the hub stops being a link list and becomes the page itself: the
* dashboard, insights, and every tracking module's settings render inline as
* stacked, labelled sections, composing the EXISTING section components
* verbatim. The per-module settings pages are no longer standalone left-nav
* entries — their routes 301-redirect to the matching anchor here
* (`/settings/medications` → `/settings/layout#medications`, …). Each module
* section keeps its module-gate: a disabled module's section does not render,
* read from the resolved `useAuth().user.modules` map (the same map the nav,
* the Modules hub, and the Insights pills gate off). The gate fails OPEN
* (`!== false`) so a stale `/me` payload never blanks a section. Vorsorge
* (preventive-care reminders) is not a toggleable module, so it always shows.
*
* Each section carries a stable anchor id (`#dashboard`, `#insights`,
* `#medications`, `#mood`, `#labs`, `#illness`, `#vorsorge`) so deep links and
* the per-module page-header cogs land on the right block. The inner card
* anchors inside each module section (`#medications-view`, `#labs-view`, …)
* keep working too.
*
* Visual rhythm mirrors the multi-group Notifications surface 1:1 — outer
* `space-y-10`, each group a `<section>` with a `space-y-0.5` heading block and
* the section body below. No new card chrome is introduced; every card paints
* through its own existing component unchanged.
* Per-module gating fails OPEN (`!== false`): a missing key reads as enabled so
* a section never silently disappears. Groups with no `moduleGate` always
* render. The filter is gated on a post-mount flag (`useMounted()`) so SSR and
* the first client paint ALWAYS emit the same fail-open list — the same
* hydration-stability contract the shell nav uses, so the module filter can
* never produce a React #418 mismatch on this surface.
*/
interface LayoutGroup {
/** Anchor id + redirect target (`/settings/layout#<id>`). */
id: string;
/** i18n key under `settings.sections.layout.<slug>.title`. */
titleKey: string;
/** i18n key under `settings.sections.layout.<slug>.description`. */
descriptionKey: string;
Body: () => React.JSX.Element | null;
/**
* When set, the group renders only while the module is enabled (fail-open,
* `!== false`). Omitted = always shown.
*/
moduleGate?: ModuleKey;
}
const LAYOUT_GROUPS: ReadonlyArray<LayoutGroup> = [
{
id: "dashboard",
titleKey: "settings.sections.layout.dashboard.title",
descriptionKey: "settings.sections.layout.dashboard.description",
Body: DashboardSection,
},
{
id: "insights",
titleKey: "settings.sections.layout.insights.title",
descriptionKey: "settings.sections.layout.insights.description",
Body: InsightsSection,
},
{
id: "medications",
titleKey: "settings.sections.layout.medications.title",
descriptionKey: "settings.sections.layout.medications.description",
Body: MedicationsSection,
moduleGate: "medications",
},
{
id: "mood",
titleKey: "settings.sections.layout.mood.title",
descriptionKey: "settings.sections.layout.mood.description",
Body: MoodSection,
moduleGate: "mood",
},
{
id: "labs",
titleKey: "settings.sections.layout.labs.title",
descriptionKey: "settings.sections.layout.labs.description",
Body: LabsSection,
moduleGate: "labs",
},
{
id: "illness",
titleKey: "settings.sections.layout.illness.title",
descriptionKey: "settings.sections.layout.illness.description",
Body: IllnessSection,
moduleGate: "illness",
},
{
id: "vorsorge",
titleKey: "settings.sections.layout.vorsorge.title",
descriptionKey: "settings.sections.layout.vorsorge.description",
Body: VorsorgeSection,
},
];
export function LayoutSection() {
const { t } = useTranslations();
const { user } = useAuth();
// v1.25.7 — per-module gating. Fail OPEN (`!== false`): a missing key, or a
// not-yet-resolved `/me` payload, reads as enabled so a section never
// silently disappears. Groups with no `moduleGate` always render.
const hydrated = useMounted();
const modules = user?.modules;
const visibleGroups = LAYOUT_GROUPS.filter(
(group) => !group.moduleGate || modules?.[group.moduleGate] !== false,
(group) =>
!hydrated || !group.moduleGate || modules?.[group.moduleGate] !== false,
);
// The visible page heading + subtitle are painted by `SettingsShell` from
// the section slug; this body stacks the per-surface sections below it.
return (
<div className="space-y-10">
{visibleGroups.map((group) => {
const { Body } = group;
return (
<section
key={group.id}
id={group.id}
className="scroll-mt-28 space-y-4"
>
<div className="space-y-0.5">
<h2 className="text-foreground text-lg font-semibold">
{t(group.titleKey)}
</h2>
<p className="text-muted-foreground text-sm">
{t(group.descriptionKey)}
</p>
</div>
<Body />
</section>
);
})}
</div>
<SettingsCard className="divide-border divide-y overflow-hidden p-0 md:p-0">
{visibleGroups.map((group) => (
<Link
key={group.id}
href={`/settings/layout/${group.id}`}
className="hover:bg-accent/50 flex items-center justify-between gap-4 p-4 transition-colors md:p-6"
>
<div className="min-w-0 space-y-0.5">
<p className="text-foreground text-sm font-medium">
{t(group.titleKey)}
</p>
<p className="text-muted-foreground text-sm">
{t(group.descriptionKey)}
</p>
</div>
<ChevronRight
className="text-muted-foreground h-5 w-5 shrink-0"
aria-hidden="true"
/>
</Link>
))}
</SettingsCard>
);
}
+7 -5
View File
@@ -145,8 +145,9 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
// v1.25.7 — "Darstellung" (Appearance) absorbs the per-module view/sort/
// order surfaces. The standalone Medikamente / Stimmung / Labor /
// Krankheit / Vorsorge nav entries are gone: each module's full settings
// now render inline inside this section, gated on the same module key. The
// old routes 301-redirect to `/settings/layout#<anchor>` (next.config.ts).
// v1.25.11 (#148) — "Darstellung" is a HUB that lists each module and links
// to its own subpage at `/settings/layout/<module>`. The old per-module
// routes 301-redirect to the matching subpage (next.config.ts).
{
slug: "layout",
titleKey: "settings.sections.layout.title",
@@ -172,7 +173,7 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
},
// v1.25.7 — Vorsorge (preventive-care reminders) folded into "Darstellung"
// alongside the other tracking modules; `/settings/vorsorge` 301-redirects
// to `/settings/layout#vorsorge` (next.config.ts).
// to `/settings/layout/vorsorge` (next.config.ts).
{
slug: "thresholds",
titleKey: "settings.sections.thresholds.title",
@@ -281,8 +282,9 @@ const LAYOUT_CHILD_SLUGS: ReadonlySet<string> = new Set([
"dashboard",
"insights",
// v1.25.7 — the per-module view/sort/order surfaces moved into the
// "Darstellung" hub. Their routes 301-redirect to `/settings/layout#<slug>`,
// but if one is ever reached directly the nav still highlights the hub.
// "Darstellung" hub. v1.25.11 (#148) — each lives on its own subpage at
// `/settings/layout/<slug>`; the legacy `/settings/<slug>` routes
// 301-redirect there, and a direct hit still highlights the hub.
"medications",
"mood",
"labs",