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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 23:18:54 +02:00
1 parent 59fea72d8b
commit 16bd43c08f
5 files changed
+101 -32

No files matched your search

@@ -20,16 +20,18 @@ function render(locale: "en" | "de" = "en") {
}
describe("<LayoutSection>", () => {
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("<LayoutSection>", () => {
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");
});
});
@@ -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<string, boolean> | 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<string, boolean>;
}) {
mockPathnameRef.value = props.pathname ?? "/settings/account";
mockModulesRef.value = props.modules;
return renderToStaticMarkup(
<I18nProvider initialLocale={props.locale ?? "en"}>
<SettingsShell active={props.active}>
@@ -163,7 +181,6 @@ describe("<SettingsShell>", () => {
"/settings/dashboard",
"/settings/insights",
"/settings/medications",
"/settings/mood",
]) {
const html = renderShell({ pathname: child });
const layoutActive =
@@ -175,12 +192,37 @@ describe("<SettingsShell>", () => {
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 =
/<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"',
);
// 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 =
/<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);
});
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("<SettingsShell>", () => {
// (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 &amp; 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 &amp; Tokens");
@@ -232,13 +276,13 @@ describe("<SettingsShell>", () => {
// 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 &amp; 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");
+2 -7
View File
@@ -5,7 +5,6 @@ import {
ChevronRight,
LayoutDashboard,
Pill,
Smile,
TrendingUp,
type LucideIcon,
} from "lucide-react";
@@ -50,12 +49,8 @@ const LAYOUT_LINKS: ReadonlyArray<LayoutLink> = [
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() {
+1 -6
View File
@@ -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"
>
<header className="space-y-2">
<SettingsHubBackLink
href="/settings/layout"
labelKey="settings.sections.layout.backToHub"
/>
<header className="space-y-1">
<h1 id="settings-section-mood-title" className="sr-only">
{t("settings.sections.mood.title")}
</h1>
+39 -4
View File
@@ -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.<slug>.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<string> = 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"
>
<ul className="flex min-w-max gap-2">
{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) {
>
<div className="sticky top-20">
<ul className="space-y-1">
{SETTINGS_SECTIONS.map((section) => {
{visibleSections.map((section) => {
const isActive = section.slug === activeSlug;
const Icon = section.icon;
return (