diff --git a/next.config.ts b/next.config.ts index ce04e9e87..92a373f73 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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/`. 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 diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index c65baad7b..b59cf7ee8 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -570,7 +570,9 @@ export default function InsightsPage() { description={t("insights.editMode.emptyDescription")} action={ } /> diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index b10436be5..3397db3a3 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -90,7 +90,7 @@ export default function LabsPage() { className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9" > diff --git a/src/app/medications/__tests__/list-view-toggle.test.tsx b/src/app/medications/__tests__/list-view-toggle.test.tsx index f1c2a45ca..db9dd60c7 100644 --- a/src/app/medications/__tests__/list-view-toggle.test.tsx +++ b/src/app/medications/__tests__/list-view-toggle.test.tsx @@ -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"'); diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index a65d0182e..bc2121edf 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -370,7 +370,7 @@ export default function MedicationsPage() { className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9" > diff --git a/src/app/mood/page.tsx b/src/app/mood/page.tsx index 645b183f5..9f63ea3c6 100644 --- a/src/app/mood/page.tsx +++ b/src/app/mood/page.tsx @@ -90,7 +90,7 @@ export default function MoodPage() { className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9" > diff --git a/src/app/settings/layout/[module]/page.tsx b/src/app/settings/layout/[module]/page.tsx new file mode 100644 index 000000000..241ec4c51 --- /dev/null +++ b/src/app/settings/layout/[module]/page.tsx @@ -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/` 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/` 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 ( + + ), + }} + > +
+ + + +
+
+ ); +} diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index 79aa15331..72d0b7540 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -110,7 +110,7 @@ export function DashboardHeader({ data-testid="dashboard-customize-shortcut" > diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx index a6559cc06..8f8d16772 100644 --- a/src/components/illness/illness-view.tsx +++ b/src/components/illness/illness-view.tsx @@ -401,7 +401,7 @@ export function IllnessView() { className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9" > diff --git a/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx b/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx index b3f9c531c..f9b21c002 100644 --- a/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx +++ b/src/components/insights/__tests__/insights-edit-mode-manager.test.tsx @@ -49,7 +49,9 @@ describe(" — 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 & detail pages in Settings"); }); diff --git a/src/components/insights/insights-edit-mode.tsx b/src/components/insights/insights-edit-mode.tsx index 7d972157d..0f6bff60d 100644 --- a/src/components/insights/insights-edit-mode.tsx +++ b/src/components/insights/insights-edit-mode.tsx @@ -279,7 +279,7 @@ export function InsightsEditMode({ the Vitals row duplicated it. Keep a quiet pointer instead. */}

diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 75159f92a..400a74126 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -882,7 +882,7 @@ function InsightsTabStripImpl({ point. */} {onRegenerate && ( diff --git a/src/components/settings/__tests__/layout-groups.test.ts b/src/components/settings/__tests__/layout-groups.test.ts new file mode 100644 index 000000000..2401d62ed --- /dev/null +++ b/src/components/settings/__tests__/layout-groups.test.ts @@ -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`, + ); + } + }); +}); diff --git a/src/components/settings/__tests__/layout-section.test.tsx b/src/components/settings/__tests__/layout-section.test.tsx index a13ac05f4..5b232197d 100644 --- a/src/components/settings/__tests__/layout-section.test.tsx +++ b/src/components/settings/__tests__/layout-section.test.tsx @@ -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 + * (`` 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: () =>

, -})); -vi.mock("@/components/settings/insights-section", () => ({ - InsightsSection: () =>
, -})); -vi.mock("@/components/settings/medications-section", () => ({ - MedicationsSection: () =>
, -})); -vi.mock("@/components/settings/mood-section", () => ({ - MoodSection: () =>
, -})); -vi.mock("@/components/settings/labs-section", () => ({ - LabsSection: () =>
, -})); -vi.mock("@/components/settings/illness-section", () => ({ - IllnessSection: () =>
, -})); -vi.mock("@/components/settings/vorsorge-section", () => ({ - VorsorgeSection: () =>
, +// 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, locale: "en" | "de" = "en") { ); } -describe("", () => { - it("stacks every surface inline with a stable anchor id (fail-open default)", () => { +describe(" 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("", () => { "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"); diff --git a/src/components/settings/__tests__/settings-card-header-coverage.test.ts b/src/components/settings/__tests__/settings-card-header-coverage.test.ts index 46dfcf65b..6e3d1bfa6 100644 --- a/src/components/settings/__tests__/settings-card-header-coverage.test.ts +++ b/src/components/settings/__tests__/settings-card-header-coverage.test.ts @@ -28,8 +28,6 @@ const SETTINGS_DIR = join(ROOT, "src/components/settings"); // Files allowed to retain a hand-rolled `

` header. const ALLOWLIST = new Set([ - // 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 diff --git a/src/components/settings/layout-groups.ts b/src/components/settings/layout-groups.ts new file mode 100644 index 000000000..a80fb1002 --- /dev/null +++ b/src/components/settings/layout-groups.ts @@ -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/`, 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: string; + /** i18n key under `settings.sections.layout..title`. */ + titleKey: string; + /** i18n key under `settings.sections.layout..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 = [ + { + 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); +} diff --git a/src/components/settings/layout-module-gate.tsx b/src/components/settings/layout-module-gate.tsx new file mode 100644 index 000000000..8d5c2f23b --- /dev/null +++ b/src/components/settings/layout-module-gate.tsx @@ -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/`). + * + * 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}; +} diff --git a/src/components/settings/layout-section.tsx b/src/components/settings/layout-section.tsx index f165a5419..cadd7d626 100644 --- a/src/components/settings/layout-section.tsx +++ b/src/components/settings/layout-section.tsx @@ -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/`. 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 `
` 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: string; - /** i18n key under `settings.sections.layout..title`. */ - titleKey: string; - /** i18n key under `settings.sections.layout..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 = [ - { - 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 ( -
- {visibleGroups.map((group) => { - const { Body } = group; - return ( -
-
-

- {t(group.titleKey)} -

-

- {t(group.descriptionKey)} -

-
- -
- ); - })} -
+ + {visibleGroups.map((group) => ( + +
+

+ {t(group.titleKey)} +

+

+ {t(group.descriptionKey)} +

+
+
); } diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 317f50a26..d90c00a71 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -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#` (next.config.ts). + // v1.25.11 (#148) — "Darstellung" is a HUB that lists each module and links + // to its own subpage at `/settings/layout/`. 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 = 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#`, - // 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/`; the legacy `/settings/` routes + // 301-redirect there, and a direct hit still highlights the hub. "medications", "mood", "labs",