mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
fa2e2c6ba2
commit
a01c9d0435
20 files changed
+451
-205
No files matched your search
+22
-10
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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"');
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
|
||||
@@ -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")}
|
||||
>
|
||||
|
||||
@@ -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 & 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
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user