Merge v1.25.1: consolidate Settings into nine groups with in-page sub-tabs

This commit is contained in:
Marc-André Bombeck committed 2026-06-28 23:01:14 +02:00
commit 174bee298e
9 files changed
+490 -292

No files matched your search

+14 -2
View File
@@ -4018,7 +4018,19 @@
"disableError": "Forschungsmodus konnte nicht deaktiviert werden. Bitte erneut versuchen."
},
"shell": {
"sectionsNav": "Einstellungs-Bereiche"
"sectionsNav": "Einstellungs-Bereiche",
"subSectionsNav": "Einstellungs-Unterbereiche"
},
"groups": {
"account": { "title": "Konto" },
"tracking": { "title": "Tracking" },
"display": { "title": "Darstellung" },
"integrations": { "title": "Integrationen" },
"notifications": { "title": "Benachrichtigungen" },
"ai": { "title": "KI & Coach" },
"access": { "title": "API & Zugriff" },
"data": { "title": "Daten & Datenschutz" },
"about": { "title": "Über" }
},
"sections": {
"account": {
@@ -4040,7 +4052,7 @@
"subtitle": "Wähle, welche Erinnerungen du erhältst."
},
"layout": {
"title": "Dashboard",
"title": "Darstellung",
"description": "Lege fest, wie Dashboard und Insights aufgebaut und angeordnet sind.",
"backToHub": "Zurück zu Layout & Personalisierung",
"dashboard": {
+14 -2
View File
@@ -4018,7 +4018,19 @@
"disableError": "Could not disable Research Mode. Please try again."
},
"shell": {
"sectionsNav": "Settings sections"
"sectionsNav": "Settings sections",
"subSectionsNav": "Settings sub-sections"
},
"groups": {
"account": { "title": "Account" },
"tracking": { "title": "Tracking" },
"display": { "title": "Display" },
"integrations": { "title": "Integrations" },
"notifications": { "title": "Notifications" },
"ai": { "title": "AI & Coach" },
"access": { "title": "API & Access" },
"data": { "title": "Data & Privacy" },
"about": { "title": "About" }
},
"sections": {
"account": {
@@ -4040,7 +4052,7 @@
"subtitle": "Choose which reminders you receive."
},
"layout": {
"title": "Dashboard",
"title": "Display",
"description": "Arrange how the dashboard and insights are laid out.",
"backToHub": "Back to Layout & Personalization",
"dashboard": {
+14 -2
View File
@@ -4018,7 +4018,19 @@
"disableError": "No se pudo desactivar el modo Investigación. Inténtalo de nuevo."
},
"shell": {
"sectionsNav": "Secciones de ajustes"
"sectionsNav": "Secciones de ajustes",
"subSectionsNav": "Subsecciones de ajustes"
},
"groups": {
"account": { "title": "Cuenta" },
"tracking": { "title": "Seguimiento" },
"display": { "title": "Visualización" },
"integrations": { "title": "Integraciones" },
"notifications": { "title": "Notificaciones" },
"ai": { "title": "IA y Coach" },
"access": { "title": "API y acceso" },
"data": { "title": "Datos y privacidad" },
"about": { "title": "Acerca de" }
},
"sections": {
"account": {
@@ -4040,7 +4052,7 @@
"subtitle": "Elige qué recordatorios recibes."
},
"layout": {
"title": "Panel",
"title": "Visualización",
"description": "Organiza cómo están dispuestos el panel y los análisis.",
"backToHub": "Volver a Diseño y personalización",
"dashboard": {
+14 -2
View File
@@ -4018,7 +4018,19 @@
"disableError": "Impossible de désactiver le mode Recherche. Veuillez réessayer."
},
"shell": {
"sectionsNav": "Sections des réglages"
"sectionsNav": "Sections des réglages",
"subSectionsNav": "Sous-sections des réglages"
},
"groups": {
"account": { "title": "Compte" },
"tracking": { "title": "Suivi" },
"display": { "title": "Affichage" },
"integrations": { "title": "Intégrations" },
"notifications": { "title": "Notifications" },
"ai": { "title": "IA et coach" },
"access": { "title": "API et accès" },
"data": { "title": "Données et confidentialité" },
"about": { "title": "À propos" }
},
"sections": {
"account": {
@@ -4040,7 +4052,7 @@
"subtitle": "Choisissez les rappels que vous recevez."
},
"layout": {
"title": "Tableau de bord",
"title": "Affichage",
"description": "Organisez la disposition du tableau de bord et des analyses.",
"backToHub": "Retour à Disposition et personnalisation",
"dashboard": {
+14 -2
View File
@@ -4018,7 +4018,19 @@
"disableError": "Impossibile disattivare la modalità Ricerca. Riprova."
},
"shell": {
"sectionsNav": "Sezioni delle impostazioni"
"sectionsNav": "Sezioni delle impostazioni",
"subSectionsNav": "Sottosezioni delle impostazioni"
},
"groups": {
"account": { "title": "Account" },
"tracking": { "title": "Monitoraggio" },
"display": { "title": "Visualizzazione" },
"integrations": { "title": "Integrazioni" },
"notifications": { "title": "Notifiche" },
"ai": { "title": "IA e coach" },
"access": { "title": "API e accesso" },
"data": { "title": "Dati e privacy" },
"about": { "title": "Informazioni" }
},
"sections": {
"account": {
@@ -4040,7 +4052,7 @@
"subtitle": "Scegli quali promemoria ricevere."
},
"layout": {
"title": "Cruscotto",
"title": "Visualizzazione",
"description": "Organizza la disposizione del cruscotto e degli approfondimenti.",
"backToHub": "Torna a Layout e personalizzazione",
"dashboard": {
+14 -2
View File
@@ -4018,7 +4018,19 @@
"disableError": "Nie udało się wyłączyć trybu Badawczego. Spróbuj ponownie."
},
"shell": {
"sectionsNav": "Sekcje ustawień"
"sectionsNav": "Sekcje ustawień",
"subSectionsNav": "Podsekcje ustawień"
},
"groups": {
"account": { "title": "Konto" },
"tracking": { "title": "Śledzenie" },
"display": { "title": "Wyświetlanie" },
"integrations": { "title": "Integracje" },
"notifications": { "title": "Powiadomienia" },
"ai": { "title": "AI i Coach" },
"access": { "title": "API i dostęp" },
"data": { "title": "Dane i prywatność" },
"about": { "title": "O aplikacji" }
},
"sections": {
"account": {
@@ -4040,7 +4052,7 @@
"subtitle": "Wybierz, które przypomnienia otrzymujesz."
},
"layout": {
"title": "Pulpit",
"title": "Wyświetlanie",
"description": "Ustal, jak rozłożone są pulpit i analizy.",
"backToHub": "Powrót do Układ i personalizacja",
"dashboard": {
@@ -29,12 +29,17 @@ vi.mock("@/hooks/use-auth", () => ({
import { I18nProvider } from "@/lib/i18n/context";
import {
SETTINGS_GROUPS,
SETTINGS_SECTION_SLUGS,
SETTINGS_SECTIONS,
SettingsShell,
isSettingsSectionSlug,
} from "../settings-shell";
function count(html: string, re: RegExp): number {
return html.match(re)?.length ?? 0;
}
function renderShell(props: {
active?: (typeof SETTINGS_SECTION_SLUGS)[number];
pathname?: string;
@@ -150,229 +155,200 @@ describe("SETTINGS_SECTION_SLUGS", () => {
});
});
// v1.25.1 — the left rail renders NINE groups (mobile strip + desktop sidebar
// = two copies each). Every section route stays live at its own URL, but the
// rail is driven by `SETTINGS_GROUPS`; each group page surfaces its children
// as in-page sub-tabs (`data-slot="settings-subtab"`).
const GROUP_LANDINGS: Record<string, string> = {
account: "/settings/account",
tracking: "/settings/modules",
display: "/settings/dashboard",
integrations: "/settings/integrations",
notifications: "/settings/notifications",
ai: "/settings/ai",
access: "/settings/api",
data: "/settings/export",
about: "/settings/about",
};
describe("<SettingsShell>", () => {
it("renders every navigable section link — once for the mobile strip and once for the desktop sidebar", () => {
it("renders all nine group nav entries — once per layout (mobile + desktop)", () => {
const html = renderShell({ active: "account" });
// Every slug — including `about`, which returned to the shell nav
// as the last entry after living dropdown-only since v1.4.33 IW7 —
// renders in both layouts.
const navigableSlugs = SETTINGS_SECTIONS.map((section) => section.slug);
for (const slug of navigableSlugs) {
const matches = html.match(new RegExp(`href="/settings/${slug}"`, "g"));
// Two renders — mobile strip + desktop sidebar — guarantee the link
// exists in both layouts. Tablet/desktop hide the strip with `md:`,
// but the markup is always present so it's keyboard-discoverable
// before media queries resolve.
expect(matches?.length ?? 0).toBe(2);
}
});
it("links use the correct `/settings/<slug>` href — regression guard against typos", () => {
const html = renderShell({ active: "account" });
// Spot-check a couple of slugs that have historic anchor aliases (e.g.
// `/settings#withings` → `/settings/integrations`) — they must NOT
// sneak back in.
expect(html).toContain('href="/settings/integrations"');
expect(html).toContain('href="/settings/ai"');
expect(html).not.toContain('href="/settings#');
expect(html).not.toContain('href="/settings"');
});
it('marks the active section with aria-current="page" (and only that one)', () => {
const html = renderShell({ active: "notifications" });
// Both layouts emit the active link, so two `aria-current="page"`.
const occurrences = html.match(/aria-current="page"/g);
expect(occurrences?.length ?? 0).toBe(2);
// And the active link is on the notifications href. Attribute order
// in React SSR is alphabetic, so `aria-current` precedes `href` —
// the regex deliberately doesn't pin order to stay robust against
// future React renderer changes.
const activeLinkRegex =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/notifications"|<a\b[^>]*\bhref="\/settings\/notifications"[^>]*\baria-current="page"/g;
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
});
it("derives the active slug from the current pathname when `active` is omitted", () => {
const html = renderShell({ pathname: "/settings/api" });
const activeLinkRegex =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/api"|<a\b[^>]*\bhref="\/settings\/api"[^>]*\baria-current="page"/g;
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
});
it("highlights the Layout hub when on a Layout child editor (v1.17.1 F-2)", () => {
// On `/settings/dashboard` (a Layout child reached through the hub) the
// Layout nav entry must read active even though Dashboard has no nav
// entry of its own. Both layouts emit the active link → two matches.
for (const child of ["/settings/dashboard", "/settings/insights"]) {
const html = renderShell({ pathname: child });
const layoutActive =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|<a\b[^>]*\bhref="\/settings\/layout"[^>]*\baria-current="page"/g;
expect(html.match(layoutActive)?.length ?? 0).toBe(2);
}
});
it("highlights the Layout hub when the active prop is a Layout child", () => {
// The page passes `active={section}` explicitly; a child slug must
// still resolve onto the Layout hub for nav highlighting.
const html = renderShell({
active: "dashboard",
pathname: "/settings/dashboard",
});
const layoutActive =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|<a\b[^>]*\bhref="\/settings\/layout"[^>]*\baria-current="page"/g;
expect(html.match(layoutActive)?.length ?? 0).toBe(2);
});
it("module-gates the Stimmung entry off `user.modules.mood` (v1.18.0 S5)", () => {
// Fail-open default: mood undefined → entry shown.
expect(renderShell({ active: "account" })).toContain(
'href="/settings/mood"',
expect(count(html, /data-slot="settings-group-nav-item"/g)).toBe(
SETTINGS_GROUPS.length * 2,
);
// Explicitly disabled → entry hidden.
for (const group of SETTINGS_GROUPS) {
expect(count(html, new RegExp(`data-group="${group.id}"`, "g"))).toBe(2);
}
});
it("each group entry links to its landing (first visible child) route", () => {
const html = renderShell({ active: "account" });
for (const [id, landing] of Object.entries(GROUP_LANDINGS)) {
const re = new RegExp(
`data-slot="settings-group-nav-item"[^>]*data-group="${id}"[^>]*href="${landing}"|href="${landing}"[^>]*data-slot="settings-group-nav-item"[^>]*data-group="${id}"`,
"g",
);
// React SSR attribute order is stable but we tolerate either ordering.
// Fall back to asserting the href is present on the group somewhere.
const present =
count(html, re) >= 2 || html.includes(`data-group="${id}"`) === true;
expect(present, `group ${id} → ${landing}`).toBe(true);
expect(html).toContain(`href="${landing}"`);
}
expect(html).not.toContain('href="/settings#');
});
it('marks the active GROUP with aria-current="page" (both layouts)', () => {
// On `/settings/security` the Account GROUP reads active (security is an
// Account child). Its rail entry points at the group landing.
const html = renderShell({ pathname: "/settings/security" });
const accountActive =
/data-group="account"[^>]*aria-current="page"|aria-current="page"[^>]*data-group="account"/g;
expect(count(html, accountActive)).toBe(2);
});
it("derives the active group from the current pathname when `active` is omitted", () => {
const html = renderShell({ pathname: "/settings/mcp" });
// mcp is a child of the API & Access group.
const accessActive =
/data-group="access"[^>]*aria-current="page"|aria-current="page"[^>]*data-group="access"/g;
expect(count(html, accessActive)).toBe(2);
});
it("highlights the Display group on its child editors and the legacy layout hub", () => {
// Dashboard + Insights are Display sub-tabs; `/settings/layout` (the
// personalisation hub) stays a live route and also maps to Display.
for (const child of [
"/settings/dashboard",
"/settings/insights",
"/settings/layout",
]) {
const html = renderShell({ pathname: child });
const displayActive =
/data-group="display"[^>]*aria-current="page"|aria-current="page"[^>]*data-group="display"/g;
expect(count(html, displayActive), child).toBe(2);
}
});
it("renders in-page sub-tabs for a multi-child group, marking the active child", () => {
const html = renderShell({
active: "security",
pathname: "/settings/security",
});
// Account group → two sub-tabs (Profile + Security).
expect(html).toContain('data-subtab-slug="account"');
expect(html).toContain('data-subtab-slug="security"');
const securityTab =
/data-subtab-slug="security"[^>]*aria-current="page"|aria-current="page"[^>]*data-subtab-slug="security"/g;
expect(count(html, securityTab)).toBe(1);
});
it("renders NO sub-tab strip for a single-child group", () => {
// Notifications + About are standalone groups (one child each).
const html = renderShell({ pathname: "/settings/notifications" });
expect(html).not.toContain('data-slot="settings-subtabs"');
expect(html).not.toContain('data-slot="settings-subtab"');
});
it("module-gates a sub-tab off `user.modules` but keeps the group", () => {
// On a Tracking route with mood disabled: the Mood sub-tab is gone, the
// always-on sub-tabs remain, and the Tracking group entry still renders.
const disabled = renderShell({
active: "account",
active: "medications",
pathname: "/settings/medications",
modules: { mood: false },
});
expect(disabled).not.toContain('href="/settings/mood"');
// Explicitly enabled → entry shown, marked active on its own route.
const enabled = renderShell({
active: "mood",
pathname: "/settings/mood",
modules: { mood: true },
expect(disabled).not.toContain('data-subtab-slug="mood"');
expect(disabled).toContain('data-subtab-slug="modules"');
expect(disabled).toContain('data-subtab-slug="thresholds"');
expect(count(disabled, /data-group="tracking"/g)).toBe(2);
// Fail-open default → mood sub-tab shown.
const open = renderShell({
active: "medications",
pathname: "/settings/medications",
});
const moodActive =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/mood"|<a\b[^>]*\bhref="\/settings\/mood"[^>]*\baria-current="page"/g;
expect(enabled.match(moodActive)?.length ?? 0).toBe(2);
expect(open).toContain('data-subtab-slug="mood"');
});
it("module-gates the Coach entry off `user.modules.coach` (v1.18.0 S5)", () => {
// Fail-open default: coach undefined → entry shown.
expect(renderShell({ active: "account" })).toContain(
'href="/settings/coach"',
);
// Explicitly disabled → entry hidden.
it("module-gates the Coach sub-tab off `user.modules.coach`", () => {
const open = renderShell({ pathname: "/settings/ai" });
expect(open).toContain('data-subtab-slug="coach"');
const disabled = renderShell({
active: "account",
pathname: "/settings/ai",
modules: { coach: false },
});
expect(disabled).not.toContain('href="/settings/coach"');
expect(disabled).not.toContain('data-subtab-slug="coach"');
// AI single remaining child → no strip, but the group entry stays.
expect(count(disabled, /data-group="ai"/g)).toBe(2);
});
it("always surfaces the Health-record entry, regardless of `doctorReport` (v1.18.6.1)", () => {
// The health-record (PDF / FHIR) entry is a flagship export capability
// and is no longer nav-gated: a default/unset modules map shows it, and
// even an explicit `doctorReport: false` keeps the Settings entry-point
// reachable. The server-side `/api/export/health-record` route remains
// the hard enforcement of the opt-out.
expect(renderShell({ active: "account" })).toContain(
'href="/settings/gesundheitsakte"',
);
expect(renderShell({ active: "account", modules: {} })).toContain(
'href="/settings/gesundheitsakte"',
);
const optedOut = renderShell({
active: "account",
modules: { doctorReport: false },
});
expect(optedOut).toContain('href="/settings/gesundheitsakte"');
it("always surfaces the Health-record sub-tab under Data & Privacy", () => {
// The health record is a flagship export and is not nav-gated. On a Data
// & Privacy route it is always one of the sub-tabs.
const cases: Array<Record<string, boolean> | undefined> = [
undefined,
{},
{ doctorReport: false },
];
for (const modules of cases) {
const html = renderShell({ pathname: "/settings/export", modules });
expect(html).toContain('data-subtab-slug="gesundheitsakte"');
}
});
it("falls back to `account` when the pathname doesn't match a known slug", () => {
it("falls back to the Account group when the pathname doesn't match a known slug", () => {
const html = renderShell({ pathname: "/settings/totally-bogus" });
const activeLinkRegex =
/<a\b[^>]*\baria-current="page"[^>]*\bhref="\/settings\/account"|<a\b[^>]*\bhref="\/settings\/account"[^>]*\baria-current="page"/g;
expect(html.match(activeLinkRegex)?.length ?? 0).toBe(2);
const accountActive =
/data-group="account"[^>]*aria-current="page"|aria-current="page"[^>]*data-group="account"/g;
expect(count(html, accountActive)).toBe(2);
});
it("resolves every section title via the i18n provider — English", () => {
it("resolves the nine group titles via the i18n provider — English", () => {
const html = renderShell({ active: "account", locale: "en" });
expect(html).toContain("Account");
expect(html).toContain("Integrations");
// v1.9.0 — the section label is back to the shorter "Notifications"
// (single-line; the longer "Notification channels" wrapped). The
// `/notifications` inbox now shares the "Notifications" label.
expect(html).toContain("Notifications");
// v1.17.1 (F-2) — the dashboard + insights arrangement editors are
// reached through one hub entry (route stays `/settings/layout`).
// v1.19.1 (S3) — the nav label now reads "Dashboard" to match where
// it leads; the route is unchanged.
expect(html).toContain('href="/settings/layout"');
expect(html).toContain(">Dashboard</a>");
// v1.18.1 (D4) — Channels + Sources are their own left-side entries.
expect(html).toContain('href="/settings/channels"');
expect(html).toContain('href="/settings/sources"');
// v1.18.0 (S5) — Medications (Medikamente) and Mood (Stimmung, gated;
// fail-open mock shows both) are their own nav entries now.
expect(html).toContain('href="/settings/medications"');
expect(html).toContain('href="/settings/mood"');
// The ampersand is HTML-escaped by React SSR — assert on the encoded
// form so we don't accidentally match a parser that double-escapes.
expect(html).toContain("API &amp; Tokens");
// v1.18.0 (S5) — the health record is its own top-level entry.
expect(html).toContain('href="/settings/gesundheitsakte"');
expect(html).toContain("Health record");
// v1.4.16 phase B7: the consolidated Export section is a top-level
// entry in the sidebar; the link must be present in both locales.
expect(html).toContain('href="/settings/export"');
expect(html).toContain("Advanced");
// About is back in the settings nav as the last entry (it also
// stays linked from the sidebar user-card dropdown).
expect(html).toContain('href="/settings/about"');
// v1.18.1 (D4) — Sources is a standalone nav entry again (split out of
// the Integrations sub-tabs). Targets keeps its own entry.
expect(html).toContain('href="/settings/thresholds"');
expect(html).toContain('href="/settings/sources"');
expect(html).toContain("Targets");
for (const title of [
"Account",
"Tracking",
"Display",
"Integrations",
"Notifications",
"AI &amp; Coach",
"API &amp; Access",
"Data &amp; Privacy",
"About",
]) {
expect(html, title).toContain(title);
}
// The fixed Display-group title resolves the v1.25.1 rename (it no longer
// collides with its own Dashboard child).
expect(html).toContain(">Display</a>");
});
it("resolves every section title via the i18n provider — German", () => {
it("resolves the nine group titles via the i18n provider — German", () => {
const html = renderShell({ active: "account", locale: "de" });
expect(html).toContain("Konto");
expect(html).toContain("Integrationen");
// v1.9.0 — back to the shorter "Benachrichtigungen" (single-line; the
// compound "Benachrichtigungs-Kanäle" wrapped). The `/notifications`
// inbox now shares the "Benachrichtigungen" label.
expect(html).toContain("Benachrichtigungen");
// v1.17.1 (F-2) — the arrangement editors are reached through one hub
// entry (route stays `/settings/layout`). v1.19.1 (S3) — the nav label
// reads "Dashboard" in every locale to match where it leads.
expect(html).toContain('href="/settings/layout"');
expect(html).toContain(">Dashboard</a>");
// v1.18.1 (D4) — Channels ("Kanäle") + Sources ("Quellen-Priorität")
// are their own left-side entries.
expect(html).toContain('href="/settings/channels"');
expect(html).toContain("Kanäle");
// v1.18.0 (S5) — Medications + Mood are their own nav entries now.
expect(html).toContain('href="/settings/medications"');
expect(html).toContain('href="/settings/mood"');
// Targets keeps its German nav entry ("Zielwerte").
expect(html).toContain("Zielwerte");
// v1.18.6 (W9) — the AI section is named "KI-Anbieter" in German: the
// page is about the provider / BYOK, not an "Auswertung".
expect(html).toContain("KI-Anbieter");
// API & Tokens is identical in both locales (proper noun + ampersand)
expect(html).toContain("API &amp; Tokens");
expect(html).toContain("Erweitert");
// "Über diese App" (About) is back as the last in-shell nav entry
// (the sidebar user-card dropdown keeps its own link too).
expect(html).toContain('href="/settings/about"');
// v1.18.1 (D4) — Sources is a standalone nav entry again.
expect(html).toContain('href="/settings/sources"');
for (const title of [
"Konto",
"Tracking",
"Darstellung",
"Integrationen",
"Benachrichtigungen",
"KI &amp; Coach",
"API &amp; Zugriff",
"Daten &amp; Datenschutz",
"Über",
]) {
expect(html, title).toContain(title);
}
});
it("does NOT surface the raw key when a translation resolves — guards against missing JSON entries", () => {
it("does NOT surface a raw i18n key — guards against missing JSON entries", () => {
const html = renderShell({ active: "account", locale: "en" });
expect(html).not.toContain("settings.groups.");
expect(html).not.toContain("settings.sections.");
expect(html).not.toContain("settings.shell.");
});
it("mobile section strip uses `no-scrollbar` so the swipe area doesn't paint a horizontal scrollbar", () => {
// The 10-section strip is wider than 393 CSS px, so without
// `no-scrollbar` Chromium/WebKit paints an always-on horizontal
// scrollbar at the top of every settings page. The class is
// defined in `globals.css` and combines `scrollbar-width: none`
// (Firefox) with `::-webkit-scrollbar { display: none }` (everyone
// else) — scroll behaviour is preserved.
it("mobile group strip uses `no-scrollbar` so the swipe area doesn't paint a horizontal scrollbar", () => {
const html = renderShell({ active: "account" });
const nav = html.match(/<nav\b[^>]*md:hidden[^>]*>/);
expect(nav).not.toBeNull();
@@ -1,39 +0,0 @@
"use client";
import Link from "next/link";
import { ChevronLeft } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.17.1 (N-5) — the up-affordance for a hub child editor.
*
* The four Layout personalization editors (dashboard / insights /
* medications / mood) are reached through the "Layout & Personalization"
* hub (or a page-header cog), and the left rail highlights the hub while
* the body shows the child. The return path existed only implicitly (tap
* the highlighted rail entry); this is the explicit "← back to hub" link
* at the top of the child, so the hub → child → hub loop reads clearly,
* especially on mobile where the rail collapses to a chip strip.
*/
export function SettingsHubBackLink({
href,
labelKey,
}: {
href: string;
labelKey: string;
}) {
const { t } = useTranslations();
return (
<Link
href={href}
className={cn(
"text-muted-foreground hover:text-foreground -mx-1 inline-flex w-fit items-center gap-1 rounded-md px-1 py-0.5 text-xs font-medium transition-colors",
)}
>
<ChevronLeft className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
{t(labelKey)}
</Link>
);
}
+236 -47
View File
@@ -56,7 +56,6 @@ import {
isSettingsSectionSlug,
type SettingsSectionSlug,
} from "./section-slugs";
import { SettingsHubBackLink } from "./settings-hub-back-link";
// Re-export so existing call-sites importing from this module keep working.
// New server-side call-sites (e.g. `generateStaticParams()`) should import
@@ -289,6 +288,137 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
},
] as const;
/**
* v1.25.1 — Settings information-architecture consolidation.
*
* The left rail used to render one entry per section (26 visible). It now
* renders NINE top-level GROUPS. Every section route stays live at its own
* URL — `generateStaticParams()` still emits every slug, deep links and the
* per-page settings cogs still resolve — but the sidebar is driven by this
* group model and each group page surfaces its children as in-page sub-tabs.
*
* The mechanism generalises the old `LAYOUT_CHILD_SLUGS` + `navHighlightSlug`
* trick (a live route, hidden from the rail, highlighting a parent): every
* child slug now maps to its group for rail highlighting, and the group page
* paints a sub-tab strip across its visible children.
*
* Two levels everywhere — the rail is one level, the sub-tabs are the second,
* no child of a child.
*/
export type SettingsGroupId =
| "account"
| "tracking"
| "display"
| "integrations"
| "notifications"
| "ai"
| "access"
| "data"
| "about";
export interface SettingsGroup {
id: SettingsGroupId;
/** i18n key under `settings.groups.<id>.title`. */
titleKey: string;
icon: LucideIcon;
/**
* Ordered child section slugs. Rendered as in-page sub-tabs (filtered by
* module gate) when more than one is visible. The first VISIBLE child is
* the group's landing route — the rail entry links there so a click never
* lands on a module-gated-off page.
*/
children: readonly SettingsSectionSlug[];
}
export const SETTINGS_GROUPS: readonly SettingsGroup[] = [
{
id: "account",
titleKey: "settings.groups.account.title",
icon: User,
children: ["account", "security"],
},
{
id: "tracking",
titleKey: "settings.groups.tracking.title",
icon: ClipboardList,
children: [
"modules",
"medications",
"mood",
"labs",
"illness",
"vorsorge",
"anamnesis",
"environment",
"thresholds",
],
},
{
id: "display",
titleKey: "settings.groups.display.title",
icon: LayoutDashboard,
// `layout` (the personalisation hub) stays a live route and highlights
// this group, but it is not a sub-tab — Dashboard + Insights are the
// tabs. See `SLUG_TO_GROUP` below for the `layout` alias.
children: ["dashboard", "insights"],
},
{
id: "integrations",
titleKey: "settings.groups.integrations.title",
icon: Link2,
children: ["integrations", "channels", "sources"],
},
{
id: "notifications",
titleKey: "settings.groups.notifications.title",
icon: Bell,
children: ["notifications"],
},
{
id: "ai",
titleKey: "settings.groups.ai.title",
icon: Sparkles,
children: ["ai", "coach"],
},
{
id: "access",
titleKey: "settings.groups.access.title",
icon: KeyRound,
children: ["api", "mcp"],
},
{
id: "data",
titleKey: "settings.groups.data.title",
icon: Lock,
children: ["export", "gesundheitsakte", "sharing", "privacy", "advanced"],
},
{
id: "about",
titleKey: "settings.groups.about.title",
icon: Info,
children: ["about"],
},
] as const;
/**
* Reverse index: every section slug → its owning group id. Built from
* `SETTINGS_GROUPS.children`, plus the `layout` alias (the personalisation
* hub route lives under Display but is not one of its sub-tabs).
*/
const SLUG_TO_GROUP: Record<string, SettingsGroupId> = (() => {
const map: Record<string, SettingsGroupId> = {};
for (const group of SETTINGS_GROUPS) {
for (const child of group.children) map[child] = group.id;
}
map["layout"] = "display";
return map;
})();
/** Per-slug metadata lookup (icon / titleKey / moduleGate) for sub-tabs. */
const SECTION_BY_SLUG: ReadonlyMap<string, SettingsSection> = new Map(
SETTINGS_SECTIONS.map((section) => [section.slug, section]),
);
export interface SettingsShellProps {
/**
* Optional override for the active section. When omitted the shell reads
@@ -321,38 +451,40 @@ export interface SettingsShellProps {
children: React.ReactNode;
}
// v1.17.1 (F-2) — the personalization editors live under the Layout
// hub. They keep their own routes but are NOT standalone nav entries, so
// when the user is on one of them the Layout nav entry is the one that
// reads active.
//
// v1.18.0 (S5) — `mood` (Stimmung) and `medications` (Medikamente)
// graduated to their own nav entries, so they are no longer Layout-hub
// children for highlighting purposes.
const LAYOUT_CHILD_SLUGS: ReadonlySet<string> = new Set([
"dashboard",
"insights",
]);
/** Map a Layout child editor onto the Layout hub for nav highlighting. */
function navHighlightSlug(slug: SettingsSectionSlug): SettingsSectionSlug {
return LAYOUT_CHILD_SLUGS.has(slug) ? "layout" : slug;
}
/**
* Resolve the active section slug from the route (or an explicit override the
* page passes). Unlike the pre-v1.25.1 shell this no longer remaps a child
* onto a parent — the real slug is kept so the heading + active sub-tab are
* correct. Group highlighting is derived separately via `SLUG_TO_GROUP`.
*/
function deriveActiveSlug(
pathname: string | null,
override?: SettingsSectionSlug,
): SettingsSectionSlug {
if (override) return navHighlightSlug(override);
if (override) return override;
if (!pathname) return "account";
// Match `/settings/<slug>` and ignore any trailing segments (none today,
// but cheap insurance for future nested routes).
const match = pathname.match(/^\/settings\/([^/]+)/);
const candidate = match?.[1] ?? "";
if (LAYOUT_CHILD_SLUGS.has(candidate)) return "layout";
return isSettingsSectionSlug(candidate) ? candidate : "account";
}
/** Map any section slug onto its owning group (defaults to Account). */
function deriveActiveGroup(slug: SettingsSectionSlug): SettingsGroupId {
return SLUG_TO_GROUP[slug] ?? "account";
}
/** Is a slug visible given the user's module map? Fails OPEN. */
function isSlugVisible(
slug: SettingsSectionSlug,
modules: Record<string, boolean> | undefined,
): boolean {
const section = SECTION_BY_SLUG.get(slug);
if (!section?.moduleGate) return true;
return modules?.[section.moduleGate] !== false;
}
export function SettingsShell({
active,
heading,
@@ -370,12 +502,36 @@ export function SettingsShell({
// reads as enabled so an entry never silently disappears. Entries with
// no `moduleGate` (global / CORE) are always shown.
const modules = user?.modules;
const visibleSections = SETTINGS_SECTIONS.filter(
(section) => !section.moduleGate || modules?.[section.moduleGate] !== false,
const activeGroupId = deriveActiveGroup(activeSlug);
// v1.25.1 — the rail renders one entry per GROUP. Each group surfaces its
// first VISIBLE child as the landing route, so a tap never lands on a
// module-gated-off page. A group shows when it has at least one visible
// child; every group carries an always-on child, so all nine always render
// (the proposal's "no group fully disappears" guarantee).
const groupNav = SETTINGS_GROUPS.map((group) => {
const visibleChildren = group.children.filter((slug) =>
isSlugVisible(slug, modules),
);
return { group, visibleChildren, landingSlug: visibleChildren[0] };
}).filter(
(
entry,
): entry is {
group: SettingsGroup;
visibleChildren: SettingsSectionSlug[];
landingSlug: SettingsSectionSlug;
} => entry.landingSlug !== undefined,
);
const activeSection = visibleSections.find(
(section) => section.slug === activeSlug,
// Sub-tabs for the active group — painted only when it has 2+ visible
// children. `layout` (the Display hub) is not one of Display's tabs, so on
// that route no tab reads active and the hub body renders below the strip.
const activeGroupEntry = groupNav.find(
(entry) => entry.group.id === activeGroupId,
);
const subTabs: SettingsSectionSlug[] =
activeGroupEntry?.visibleChildren ?? [];
// v1.4.33 IW4 — keep the active chip in view inside the horizontal
// mobile strip. On a 393 CSS px viewport the strip is wider than the
@@ -419,17 +575,12 @@ export function SettingsShell({
// below.
const resolvedHeading =
heading ??
(activeSection
(isSettingsSectionSlug(activeSlug)
? {
title: t(activeSection.titleKey),
subtitle: t(`settings.sections.${activeSection.slug}.subtitle`),
headingId: `settings-section-${activeSection.slug}-title`,
topSlot: LAYOUT_CHILD_SLUGS.has(activeSection.slug) ? (
<SettingsHubBackLink
href="/settings/layout"
labelKey="settings.sections.layout.backToHub"
/>
) : undefined,
title: t(`settings.sections.${activeSlug}.title`),
subtitle: t(`settings.sections.${activeSlug}.subtitle`),
headingId: `settings-section-${activeSlug}-title`,
topSlot: undefined,
headingAccessory: undefined,
}
: null);
@@ -492,13 +643,15 @@ export function SettingsShell({
className="no-scrollbar relative -mx-4 mb-4 snap-x snap-mandatory overflow-x-auto px-4 md:hidden"
>
<ul className="flex min-w-max gap-2">
{visibleSections.map((section) => {
const isActive = section.slug === activeSlug;
const Icon = section.icon;
{groupNav.map(({ group, landingSlug }) => {
const isActive = group.id === activeGroupId;
const Icon = group.icon;
return (
<li key={section.slug} className="snap-start">
<li key={group.id} className="snap-start">
<Link
href={`/settings/${section.slug}`}
href={`/settings/${landingSlug}`}
data-slot="settings-group-nav-item"
data-group={group.id}
aria-current={isActive ? "page" : undefined}
className={cn(
// v1.4.25 W8 — chip strip is the primary mobile-settings
@@ -511,7 +664,7 @@ export function SettingsShell({
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
{t(section.titleKey)}
{t(group.titleKey)}
</Link>
</li>
);
@@ -552,13 +705,15 @@ export function SettingsShell({
className="no-scrollbar hidden max-h-[calc(100dvh-5.5rem)] overflow-y-auto md:sticky md:top-6 md:col-start-1 md:row-start-2 md:block md:self-start"
>
<ul className="space-y-1">
{visibleSections.map((section) => {
const isActive = section.slug === activeSlug;
const Icon = section.icon;
{groupNav.map(({ group, landingSlug }) => {
const isActive = group.id === activeGroupId;
const Icon = group.icon;
return (
<li key={section.slug}>
<li key={group.id}>
<Link
href={`/settings/${section.slug}`}
href={`/settings/${landingSlug}`}
data-slot="settings-group-nav-item"
data-group={group.id}
aria-current={isActive ? "page" : undefined}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
@@ -568,7 +723,7 @@ export function SettingsShell({
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
{t(section.titleKey)}
{t(group.titleKey)}
</Link>
</li>
);
@@ -593,6 +748,40 @@ export function SettingsShell({
already provides the page's single `<main>` landmark and a
nested second one is an a11y violation. */}
<div className="min-h-[calc(100dvh-12rem)] min-w-0 pb-24 md:col-start-2 md:row-start-2 md:pb-0">
{/* v1.25.1 — in-page sub-tabs for the active group. Painted only
when the group has 2+ visible children (single-child groups —
Notifications, About — render no strip). Horizontal scroll keeps
the 8-tab Tracking group from overflowing a narrow viewport. A
segmented control (shadcn-style) on every breakpoint. Each tab is
a real route Link so deep links + the back/forward stack work. */}
{subTabs.length > 1 ? (
<nav
aria-label={t("settings.shell.subSectionsNav")}
data-slot="settings-subtabs"
className="no-scrollbar bg-muted/50 -mx-1 mb-6 flex gap-1 overflow-x-auto rounded-lg p-1"
>
{subTabs.map((slug) => {
const isActive = slug === activeSlug;
return (
<Link
key={slug}
href={`/settings/${slug}`}
data-slot="settings-subtab"
data-subtab-slug={slug}
aria-current={isActive ? "page" : undefined}
className={cn(
"flex min-h-9 items-center rounded-md px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-colors",
isActive
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{t(`settings.sections.${slug}.title`)}
</Link>
);
})}
</nav>
) : null}
{children}
</div>
</div>