mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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 & 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("<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 & 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");
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user