refactor(settings): gather layout personalization under one home

Personalizing how the app is laid out was split across four unrelated
settings sections — Dashboard, Insights, Medications, Mood — with no
shared framing, so the same concept (arrange, reorder, show/hide) read
as four disconnected places, plus the settings nav carried four separate
arrange entries.

Add one Layout & Personalization hub that presents the four editors as a
consistent set of linked cards and replaces the four scattered nav
entries with a single home. Each editor keeps its own route and mutation
flow — deep links, the page-header cogs, and the hub's links all still
resolve — and a Layout child route now highlights the hub in the nav.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 00:20:16 +02:00
1 parent 9bedd6b4f8
commit cf31211fbc
13 files changed
+361 -42

No files matched your search

+20
View File
@@ -3814,6 +3814,26 @@
"title": "Benachrichtigungen",
"description": "Live-Übersicht aller konfigurierten Kanäle."
},
"layout": {
"title": "Layout & Personalisierung",
"description": "Lege fest, wie deine App aufgebaut ist — ein Ort für Dashboard, Insights, Medikamente und Stimmung.",
"dashboard": {
"title": "Dashboard",
"description": "Kachel-Layout und Reihenfolge."
},
"insights": {
"title": "Insights",
"description": "Reihenfolge der Übersicht und der Navigations-Pills."
},
"medications": {
"title": "Medikamente",
"description": "Listenansicht und Reihenfolge der Medikamente."
},
"mood": {
"title": "Stimmungs-Tags",
"description": "Gruppen, Tags und Reihenfolge der Tag-Auswahl."
}
},
"dashboard": {
"title": "Dashboard",
"description": "Kachel-Layout und Reihenfolge."
+20
View File
@@ -3814,6 +3814,26 @@
"title": "Notifications",
"description": "Live overview of every configured channel."
},
"layout": {
"title": "Layout & Personalization",
"description": "Arrange how your app is laid out — one home for the dashboard, insights, medications, and mood.",
"dashboard": {
"title": "Dashboard",
"description": "Tile layout and order."
},
"insights": {
"title": "Insights",
"description": "Overview sections and navigation-pill order."
},
"medications": {
"title": "Medications",
"description": "List view and medication order."
},
"mood": {
"title": "Mood tags",
"description": "Groups, tags, and order of the mood picker."
}
},
"dashboard": {
"title": "Dashboard",
"description": "Tile layout and order."
+20
View File
@@ -3814,6 +3814,26 @@
"title": "Notificaciones",
"description": "Vista en vivo de cada canal configurado."
},
"layout": {
"title": "Diseño y personalización",
"description": "Organiza cómo está dispuesta tu app — un único lugar para el panel, los análisis, los medicamentos y el ánimo.",
"dashboard": {
"title": "Panel",
"description": "Diseño y orden de los mosaicos."
},
"insights": {
"title": "Análisis",
"description": "Orden de la vista general y de las pestañas de navegación."
},
"medications": {
"title": "Medicamentos",
"description": "Vista de lista y orden de los medicamentos."
},
"mood": {
"title": "Etiquetas de ánimo",
"description": "Grupos, etiquetas y orden del selector de ánimo."
}
},
"dashboard": {
"title": "Panel",
"description": "Diseño y orden de los mosaicos."
+20
View File
@@ -3814,6 +3814,26 @@
"title": "Notifications",
"description": "Aperçu en direct de chaque canal configuré."
},
"layout": {
"title": "Disposition et personnalisation",
"description": "Organisez la disposition de votre application — un seul endroit pour le tableau de bord, les analyses, les médicaments et l’humeur.",
"dashboard": {
"title": "Tableau de bord",
"description": "Disposition et ordre des tuiles."
},
"insights": {
"title": "Analyses",
"description": "Ordre de la vue d'ensemble et des onglets de navigation."
},
"medications": {
"title": "Médicaments",
"description": "Vue de la liste et ordre des médicaments."
},
"mood": {
"title": "Étiquettes d’humeur",
"description": "Groupes, étiquettes et ordre du sélecteur d’humeur."
}
},
"dashboard": {
"title": "Tableau de bord",
"description": "Disposition et ordre des tuiles."
+20
View File
@@ -3814,6 +3814,26 @@
"title": "Notifiche",
"description": "Panoramica in tempo reale di ogni canale configurato."
},
"layout": {
"title": "Layout e personalizzazione",
"description": "Organizza la disposizione della tua app — un unico posto per cruscotto, approfondimenti, farmaci e umore.",
"dashboard": {
"title": "Cruscotto",
"description": "Layout e ordine dei riquadri."
},
"insights": {
"title": "Approfondimenti",
"description": "Ordine della panoramica e delle schede di navigazione."
},
"medications": {
"title": "Farmaci",
"description": "Vista elenco e ordine dei farmaci."
},
"mood": {
"title": "Tag dell’umore",
"description": "Gruppi, tag e ordine del selettore dell’umore."
}
},
"dashboard": {
"title": "Cruscotto",
"description": "Layout e ordine dei riquadri."
+20
View File
@@ -3814,6 +3814,26 @@
"title": "Powiadomienia",
"description": "Podgląd na żywo każdego skonfigurowanego kanału."
},
"layout": {
"title": "Układ i personalizacja",
"description": "Ustal, jak rozłożona jest Twoja aplikacja — jedno miejsce dla pulpitu, analiz, leków i nastroju.",
"dashboard": {
"title": "Pulpit",
"description": "Układ i kolejność kafelków."
},
"insights": {
"title": "Analizy",
"description": "Kolejność przeglądu i pigułek nawigacyjnych."
},
"medications": {
"title": "Leki",
"description": "Widok listy i kolejność leków."
},
"mood": {
"title": "Tagi nastroju",
"description": "Grupy, tagi i kolejność w wyborze nastroju."
}
},
"dashboard": {
"title": "Pulpit",
"description": "Układ i kolejność kafelków."
+2
View File
@@ -10,6 +10,7 @@ import { DashboardSection } from "@/components/settings/dashboard-section";
import { ExportSection } from "@/components/settings/export-section";
import { InsightsSection } from "@/components/settings/insights-section";
import { IntegrationsSection } from "@/components/settings/integrations-section";
import { LayoutSection } from "@/components/settings/layout-section";
import { MedicationsSection } from "@/components/settings/medications-section";
import { MoodSection } from "@/components/settings/mood-section";
import { NotificationsSection } from "@/components/settings/notifications-section";
@@ -51,6 +52,7 @@ const SECTION_COMPONENTS: Record<
ai: AiSection,
integrations: IntegrationsSection,
notifications: NotificationsSection,
layout: LayoutSection,
dashboard: DashboardSection,
insights: InsightsSection,
medications: MedicationsSection,
@@ -0,0 +1,48 @@
/**
* v1.17.1 (F-2) — the "Layout & Personalization" hub.
*
* The hub is the single front door for the four personalization editors.
* It must link to each of them with consistent framing so the concept
* reads as one home rather than four scattered settings sections.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { LayoutSection } from "../layout-section";
function render(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<LayoutSection />
</I18nProvider>,
);
}
describe("<LayoutSection>", () => {
it("links to all four personalization editors", () => {
const html = render();
for (const href of [
"/settings/dashboard",
"/settings/insights",
"/settings/medications",
"/settings/mood",
]) {
expect(html).toContain(`href="${href}"`);
}
});
it("renders the hub heading and a card per editor", () => {
const html = render();
expect(html).toContain("Dashboard");
expect(html).toContain("Insights");
expect(html).toContain("Medications");
expect(html).toContain("Mood tags");
});
it("resolves its copy in German too", () => {
const html = render("de");
expect(html).toContain("Medikamente");
expect(html).toContain("Stimmungs-Tags");
});
});
@@ -56,10 +56,16 @@ describe("SETTINGS_SECTION_SLUGS", () => {
// v1.17 — `mood` sits between `medications` and `thresholds`: the
// mood-tag management surface (groups, custom tags, hide/archive,
// picker order), reached from the /mood page header's wrench glyph.
// v1.17.1 (F-2) — `layout` is the one "Layout & Personalization" home
// and sits after `notifications`. The four personalization editor
// slugs (`dashboard`, `insights`, `medications`, `mood`) keep their
// routes so deep links resolve, but they are reached through the
// Layout hub instead of four standalone nav entries.
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
"account",
"integrations",
"notifications",
"layout",
"dashboard",
"insights",
"medications",
@@ -140,6 +146,32 @@ describe("<SettingsShell>", () => {
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",
"/settings/medications",
"/settings/mood",
]) {
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: "mood", pathname: "/settings/mood" });
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("falls back to `account` when the pathname doesn't match a known slug", () => {
const html = renderShell({ pathname: "/settings/totally-bogus" });
const activeLinkRegex =
@@ -155,16 +187,14 @@ describe("<SettingsShell>", () => {
// (single-line; the longer "Notification channels" wrapped). The
// `/notifications` inbox now shares the "Notifications" label.
expect(html).toContain("Notifications");
expect(html).toContain("Dashboard");
expect(html).toContain("Insights");
// v1.16.10 — the /medications customise surface is its own nav entry
// between Insights and Targets.
expect(html).toContain('href="/settings/medications"');
expect(html).toContain("Medications");
// v1.17 — the mood-tag management surface sits between Medications
// and Targets.
expect(html).toContain('href="/settings/mood"');
expect(html).toContain("Mood tags");
// v1.17.1 (F-2) — the four personalization editors (Dashboard,
// Insights, Medications, Mood) are reached through one Layout hub
// entry; the hub itself is the single nav entry for the concept.
expect(html).toContain('href="/settings/layout"');
expect(html).toContain("Layout &amp; Personalization");
// The four editors are no longer standalone nav entries.
expect(html).not.toContain('href="/settings/medications"');
expect(html).not.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");
@@ -192,17 +222,13 @@ describe("<SettingsShell>", () => {
// compound "Benachrichtigungs-Kanäle" wrapped). The `/notifications`
// inbox now shares the "Benachrichtigungen" label.
expect(html).toContain("Benachrichtigungen");
// v1.4.3: the Settings sub-section formerly labelled "Übersicht" is now
// "Dashboard" (matching the term users see in the main nav). The
// per-metric overrides moved out into their own "Persönliche Zielwerte"
// section, which is the new entry below the Dashboard one.
expect(html).toContain("Dashboard");
// v1.16.10 — the /medications customise surface ("Medikamente").
expect(html).toContain('href="/settings/medications"');
expect(html).toContain("Medikamente");
// v1.17 — the mood-tag management surface ("Stimmungs-Tags").
expect(html).toContain('href="/settings/mood"');
expect(html).toContain("Stimmungs-Tags");
// v1.17.1 (F-2) — the four personalization editors are reached through
// one Layout hub entry ("Layout & Personalisierung"); they are no
// longer standalone German nav entries.
expect(html).toContain('href="/settings/layout"');
expect(html).toContain("Layout &amp; Personalisierung");
expect(html).not.toContain('href="/settings/medications"');
expect(html).not.toContain('href="/settings/mood"');
// v1.8.7.1 — Targets and Sources are two separate German nav
// entries again: "Zielwerte" and "Quellen".
expect(html).toContain("Zielwerte");
+110
View File
@@ -0,0 +1,110 @@
"use client";
import Link from "next/link";
import {
ChevronRight,
LayoutDashboard,
Pill,
Smile,
TrendingUp,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.17.1 (F-2) — the one "Layout & Personalization" home.
*
* Before this section, "how my app is laid out" was scattered across four
* unrelated settings sections — Dashboard, Insights, Medications, Mood —
* with no shared framing, so the same concept (arrange / reorder / show-
* hide) read as four disconnected places. This section is the single
* front door: it presents the four personalization surfaces as one
* consistent set of cards and links to each editor, which all keep their
* own routes and mutation flows intact. One concept, one home.
*/
interface LayoutLink {
href: string;
icon: LucideIcon;
titleKey: string;
descriptionKey: string;
}
const LAYOUT_LINKS: ReadonlyArray<LayoutLink> = [
{
href: "/settings/dashboard",
icon: LayoutDashboard,
titleKey: "settings.sections.layout.dashboard.title",
descriptionKey: "settings.sections.layout.dashboard.description",
},
{
href: "/settings/insights",
icon: TrendingUp,
titleKey: "settings.sections.layout.insights.title",
descriptionKey: "settings.sections.layout.insights.description",
},
{
href: "/settings/medications",
icon: Pill,
titleKey: "settings.sections.layout.medications.title",
descriptionKey: "settings.sections.layout.medications.description",
},
{
href: "/settings/mood",
icon: Smile,
titleKey: "settings.sections.layout.mood.title",
descriptionKey: "settings.sections.layout.mood.description",
},
];
export function LayoutSection() {
const { t } = useTranslations();
return (
<section
aria-labelledby="settings-section-layout-title"
className="space-y-6"
>
<header className="space-y-1">
<h1 id="settings-section-layout-title" className="sr-only">
{t("settings.sections.layout.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.layout.description")}
</p>
</header>
<ul className="space-y-3">
{LAYOUT_LINKS.map((link) => {
const Icon = link.icon;
return (
<li key={link.href}>
<Link
href={link.href}
className={cn(
"bg-card border-border hover:bg-accent/40 group flex items-center gap-4 rounded-xl border p-4 transition-colors sm:p-5",
)}
>
<Icon
className="text-muted-foreground h-5 w-5 shrink-0"
aria-hidden="true"
/>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-semibold">{t(link.titleKey)}</p>
<p className="text-muted-foreground text-xs">
{t(link.descriptionKey)}
</p>
</div>
<ChevronRight
className="text-muted-foreground/60 group-hover:text-foreground h-4 w-4 shrink-0 transition-colors"
aria-hidden="true"
/>
</Link>
</li>
);
})}
</ul>
</section>
);
}
@@ -37,6 +37,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
account: User,
integrations: Link2,
notifications: Bell,
layout: LayoutDashboard,
dashboard: LayoutDashboard,
insights: TrendingUp,
medications: Pill,
+8
View File
@@ -20,10 +20,18 @@
// v1.17 — `mood` sits after `medications`: the mood-tag management
// surface (groups, custom tags, hide/archive, picker order), reached
// from the /mood page header's wrench glyph.
// v1.17.1 (F-2) — `layout` is the one "Layout & Personalization" home.
// The dashboard / insights / medications / mood personalization editors
// each keep their own route (deep links, page-header cogs, and the hub's
// own links all resolve), but the settings NAV now surfaces a single
// `layout` entry instead of four scattered "arrange" entries, so the
// concept reads as one place. The four editor slugs stay in this list so
// their routes still resolve; they are simply not listed in the shell nav.
export const SETTINGS_SECTION_SLUGS = [
"account",
"integrations",
"notifications",
"layout",
"dashboard",
"insights",
"medications",
+25 -21
View File
@@ -25,12 +25,9 @@ import {
Layers,
LayoutDashboard,
Link2,
Pill,
Settings2,
SlidersHorizontal,
Smile,
Sparkles,
TrendingUp,
User,
type LucideIcon,
} from "lucide-react";
@@ -92,26 +89,16 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
titleKey: "settings.sections.notifications.title",
icon: Bell,
},
// v1.17.1 (F-2) — one "Layout & Personalization" nav entry replaces the
// four scattered Dashboard / Insights / Medications / Mood "arrange"
// entries. Those routes still resolve (deep links, page-header cogs, and
// the hub's own links all work); they are simply reached through the
// Layout hub now, so "how my app is laid out" reads as one home.
{
slug: "dashboard",
titleKey: "settings.sections.dashboard.title",
slug: "layout",
titleKey: "settings.sections.layout.title",
icon: LayoutDashboard,
},
{
slug: "insights",
titleKey: "settings.sections.insights.title",
icon: TrendingUp,
},
{
slug: "medications",
titleKey: "settings.sections.medications.title",
icon: Pill,
},
{
slug: "mood",
titleKey: "settings.sections.mood.title",
icon: Smile,
},
{
slug: "thresholds",
titleKey: "settings.sections.thresholds.title",
@@ -151,16 +138,33 @@ export interface SettingsShellProps {
children: React.ReactNode;
}
// v1.17.1 (F-2) — the four 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.
const LAYOUT_CHILD_SLUGS: ReadonlySet<string> = new Set([
"dashboard",
"insights",
"medications",
"mood",
]);
/** 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;
}
function deriveActiveSlug(
pathname: string | null,
override?: SettingsSectionSlug,
): SettingsSectionSlug {
if (override) return override;
if (override) return navHighlightSlug(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";
}