From 16bd43c08f130118024bddf15a0b138b656a1c4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 15 Jun 2026 23:18:54 +0200 Subject: [PATCH] feat(settings): move mood settings into a dedicated Stimmung entry Promote the mood-tag management surface from a hidden Layout-hub child to its own standalone settings nav entry, shown only when the mood module is enabled. The shell now module-gates per-submodule entries off the resolved `useAuth().user.modules` map (fail-open: a missing key reads as enabled). The Layout hub keeps only genuine layout/personalisation editors. --- .../__tests__/layout-section.test.tsx | 8 +-- .../__tests__/settings-shell.test.tsx | 66 +++++++++++++++---- src/components/settings/layout-section.tsx | 9 +-- src/components/settings/mood-section.tsx | 7 +- src/components/settings/settings-shell.tsx | 43 ++++++++++-- 5 files changed, 101 insertions(+), 32 deletions(-) diff --git a/src/components/settings/__tests__/layout-section.test.tsx b/src/components/settings/__tests__/layout-section.test.tsx index 422fe8fcd..c4c9a35ed 100644 --- a/src/components/settings/__tests__/layout-section.test.tsx +++ b/src/components/settings/__tests__/layout-section.test.tsx @@ -20,16 +20,18 @@ function render(locale: "en" | "de" = "en") { } describe("", () => { - it("links to all four personalization editors", () => { + it("links to the personalization editors it still hosts", () => { const html = render(); for (const href of [ "/settings/dashboard", "/settings/insights", "/settings/medications", - "/settings/mood", ]) { expect(html).toContain(`href="${href}"`); } + // v1.18.0 (S5) — Mood (Stimmung) graduated to its own module-gated + // nav entry and is no longer linked from the Layout hub. + expect(html).not.toContain('href="/settings/mood"'); }); it("renders the hub heading and a card per editor", () => { @@ -37,12 +39,10 @@ describe("", () => { 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"); }); }); diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index 644e9969a..09f1e65c4 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -11,6 +11,22 @@ vi.mock("next/navigation", () => ({ usePathname: () => mockPathnameRef.value, })); +// v1.18.0 (S5) — the shell module-gates per-submodule nav entries off +// `useAuth().user.modules`. The mock returns a user whose modules map is +// mutated per-test via `mockModulesRef`. `undefined` (the default) means +// every gate fails open, so all entries render. +const mockModulesRef: { value: Record | undefined } = { + value: undefined, +}; +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { id: "u1", role: "USER", modules: mockModulesRef.value }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + import { I18nProvider } from "@/lib/i18n/context"; import { SETTINGS_SECTION_SLUGS, @@ -23,8 +39,10 @@ function renderShell(props: { active?: (typeof SETTINGS_SECTION_SLUGS)[number]; pathname?: string; locale?: "en" | "de"; + modules?: Record; }) { mockPathnameRef.value = props.pathname ?? "/settings/account"; + mockModulesRef.value = props.modules; return renderToStaticMarkup( @@ -163,7 +181,6 @@ describe("", () => { "/settings/dashboard", "/settings/insights", "/settings/medications", - "/settings/mood", ]) { const html = renderShell({ pathname: child }); const layoutActive = @@ -175,12 +192,37 @@ describe("", () => { 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 html = renderShell({ + active: "medications", + pathname: "/settings/medications", + }); const layoutActive = /]*\baria-current="page"[^>]*\bhref="\/settings\/layout"|]*\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"', + ); + // Explicitly disabled → entry hidden. + const disabled = renderShell({ + active: "account", + 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 }, + }); + const moodActive = + /]*\baria-current="page"[^>]*\bhref="\/settings\/mood"|]*\bhref="\/settings\/mood"[^>]*\baria-current="page"/g; + expect(enabled.match(moodActive)?.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 = @@ -196,14 +238,16 @@ describe("", () => { // (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 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. + // v1.17.1 (F-2) — the personalization editors (Dashboard, Insights, + // Medications) are reached through one Layout hub entry; the hub + // itself is the single nav entry for that concept. expect(html).toContain('href="/settings/layout"'); expect(html).toContain("Layout & Personalization"); - // The four editors are no longer standalone nav entries. + // Dashboard / Insights / Medications stay Layout-hub children. expect(html).not.toContain('href="/settings/medications"'); - expect(html).not.toContain('href="/settings/mood"'); + // v1.18.0 (S5) — Mood (Stimmung) graduated to its own module-gated + // nav entry. With the default fail-open mock it renders. + 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 & Tokens"); @@ -232,13 +276,13 @@ describe("", () => { // compound "Benachrichtigungs-Kanäle" wrapped). The `/notifications` // inbox now shares the "Benachrichtigungen" label. expect(html).toContain("Benachrichtigungen"); - // 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. + // v1.17.1 (F-2) — the personalization editors are reached through one + // Layout hub entry ("Layout & Personalisierung"). expect(html).toContain('href="/settings/layout"'); expect(html).toContain("Layout & Personalisierung"); expect(html).not.toContain('href="/settings/medications"'); - expect(html).not.toContain('href="/settings/mood"'); + // v1.18.0 (S5) — Mood (Stimmung) is its own nav entry now. + expect(html).toContain('href="/settings/mood"'); // v1.18.0 (S3) — Targets keeps its German nav entry ("Zielwerte"); // Sources folded into Integrations as a sub-tab. expect(html).toContain("Zielwerte"); diff --git a/src/components/settings/layout-section.tsx b/src/components/settings/layout-section.tsx index c9ed1814a..8ed42237e 100644 --- a/src/components/settings/layout-section.tsx +++ b/src/components/settings/layout-section.tsx @@ -5,7 +5,6 @@ import { ChevronRight, LayoutDashboard, Pill, - Smile, TrendingUp, type LucideIcon, } from "lucide-react"; @@ -50,12 +49,8 @@ const LAYOUT_LINKS: ReadonlyArray = [ 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", - }, + // v1.18.0 (S5) — Mood (Stimmung) graduated to its own top-level, + // module-gated nav entry, so it is no longer linked from the Layout hub. ]; export function LayoutSection() { diff --git a/src/components/settings/mood-section.tsx b/src/components/settings/mood-section.tsx index 675a75547..d8a82248e 100644 --- a/src/components/settings/mood-section.tsx +++ b/src/components/settings/mood-section.tsx @@ -6,7 +6,6 @@ import { ArchivedTagsCard } from "@/components/mood/manage/archived-tags-card"; import { TagGroupsCard } from "@/components/mood/manage/tag-groups-card"; import { TagManagerCard } from "@/components/mood/manage/tag-manager-card"; import { useMoodTagManage } from "@/components/mood/manage/use-mood-tag-manage"; -import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; @@ -45,11 +44,7 @@ export function MoodSection() { aria-labelledby="settings-section-mood-title" className="space-y-6" > -
- +

{t("settings.sections.mood.title")}

diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index de7c0257b..1fc8840c3 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -28,6 +28,7 @@ import { Link2, Settings2, SlidersHorizontal, + Smile, Sparkles, User, type LucideIcon, @@ -35,7 +36,9 @@ import { import { cn } from "@/lib/utils"; import { scrollBehaviorForUser } from "@/lib/motion"; +import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; +import type { ModuleKey } from "@/lib/modules/registry"; import { SETTINGS_SECTION_SLUGS, isSettingsSectionSlug, @@ -60,6 +63,14 @@ interface SettingsSection { /** i18n key under `settings.sections..title`. */ titleKey: string; icon: LucideIcon; + /** + * v1.18.0 (S5) — per-submodule entries are listed only when their module + * is enabled. When set, the nav entry hides if the resolved + * `useAuth().user.modules` map has the key explicitly `false`. The gate + * fails OPEN (a missing key reads as enabled) so a stale `/me` payload + * never blanks an entry. Omitted = always shown (global / CORE entries). + */ + moduleGate?: ModuleKey; } /** @@ -110,6 +121,15 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ titleKey: "settings.sections.layout.title", icon: LayoutDashboard, }, + // v1.18.0 (S5) — Stimmung: the mood-tag management surface gets its own + // nav entry, shown only when the mood module is enabled. Was a hidden + // child of the Layout hub. + { + slug: "mood", + titleKey: "settings.sections.mood.title", + icon: Smile, + moduleGate: "mood", + }, { slug: "thresholds", titleKey: "settings.sections.thresholds.title", @@ -151,15 +171,17 @@ export interface SettingsShellProps { children: React.ReactNode; } -// v1.17.1 (F-2) — the four personalization editors live under the Layout +// 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) graduated to its own nav entry, so it +// is no longer a Layout-hub child for highlighting purposes. const LAYOUT_CHILD_SLUGS: ReadonlySet = new Set([ "dashboard", "insights", "medications", - "mood", ]); /** Map a Layout child editor onto the Layout hub for nav highlighting. */ @@ -184,8 +206,21 @@ function deriveActiveSlug( export function SettingsShell({ active, children }: SettingsShellProps) { const pathname = usePathname(); const { t } = useTranslations(); + const { user } = useAuth(); const activeSlug = deriveActiveSlug(pathname, active); + // v1.18.0 (S5) — per-submodule entries are listed only when their module + // is enabled. Read from the resolved `useAuth().user.modules` map (the + // same map the Module hub, nav, and Insights pills gate off). Fail OPEN + // (`!== false`): a missing key, or a not-yet-resolved `/me` payload, + // 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, + ); + // 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 // viewport and the rightmost chips sit off-screen; a user who tapped @@ -247,7 +282,7 @@ export function SettingsShell({ active, children }: SettingsShellProps) { className="no-scrollbar -mx-4 mb-4 snap-x snap-mandatory overflow-x-auto px-4 md:hidden" >
    - {SETTINGS_SECTIONS.map((section) => { + {visibleSections.map((section) => { const isActive = section.slug === activeSlug; const Icon = section.icon; return ( @@ -282,7 +317,7 @@ export function SettingsShell({ active, children }: SettingsShellProps) { >
      - {SETTINGS_SECTIONS.map((section) => { + {visibleSections.map((section) => { const isActive = section.slug === activeSlug; const Icon = section.icon; return (