feat(settings): make Benachrichtigungen the single module-gated reminder-types home

Consolidate every reminder TYPE onto the Notifications screen and drop the
redundant stacked breadcrumb for one clean heading. Each reminder-type card
is shown only when its module is enabled, read from the resolved
useAuth().user.modules map (mood card on mood, Coach nudge on coach); the
gate fails open so a stale /me payload never blanks a card. Low-stock maps
to the medications CORE domain and Vorsorge (preventive care) is not a
toggleable module, so both stay shown — the Vorsorge editor embeds in place.
The delivery channels keep a single concise pointer to Integrations rather
than a duplicated section. Reword the heading copy and drop the now-unused
settings.sections.reminders.* keys across all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 23:06:45 +02:00
1 parent fccdcb378e
commit 604778f166
9 files changed
+180 -246

No files matched your search

@@ -0,0 +1,84 @@
/**
* v1.18.0 (S4) — module-gated per-type visibility on the consolidated
* "Benachrichtigungen" reminder-types home.
*
* The mood reminder shows only when the `mood` module is enabled; the Coach
* nudge only when `coach` is enabled. Low-stock maps to the medications CORE
* domain (always shown). The gate fails OPEN, so a `/me` payload without a
* module map keeps every card visible.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/settings/notifications",
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: null, isLoading: false, refetch: vi.fn() }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
}));
// The auth mock is driven per-test via a mutable ref so each render can pick
// a different module map without re-mocking the module.
const authRef: { modules?: Record<string, boolean> } = {};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", username: "t", role: "USER", modules: authRef.modules },
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { NotificationsSection } from "../notifications-section";
function render(modules?: Record<string, boolean>) {
authRef.modules = modules;
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<NotificationsSection />
</I18nProvider>,
);
}
describe("<NotificationsSection> module-gated reminder types", () => {
it("shows mood + coach cards when no module map (fails open)", () => {
const html = render(undefined);
expect(html).toContain('id="mood-reminder"');
expect(html).toContain('id="coach-nudge"');
expect(html).toContain('id="low-stock"');
});
it("hides the mood card when the mood module is disabled", () => {
const html = render({ mood: false });
expect(html).not.toContain('id="mood-reminder"');
// Coach + low-stock are unaffected.
expect(html).toContain('id="coach-nudge"');
expect(html).toContain('id="low-stock"');
});
it("hides the coach card when the coach module is disabled", () => {
const html = render({ coach: false });
expect(html).not.toContain('id="coach-nudge"');
expect(html).toContain('id="mood-reminder"');
expect(html).toContain('id="low-stock"');
});
it("always shows low-stock (medications is a CORE domain)", () => {
const html = render({ mood: false, coach: false });
expect(html).toContain('id="low-stock"');
expect(html).not.toContain('id="mood-reminder"');
expect(html).not.toContain('id="coach-nudge"');
});
it("shows the channels pointer (delivery channels live under Integrations)", () => {
const html = render({});
expect(html).toContain('data-slot="notifications-channels-cross-link"');
expect(html).toContain('href="/settings/integrations"');
});
});
@@ -182,12 +182,24 @@ describe("settings sections — SSR smoke", () => {
expect(html).toContain("Withings");
});
it("<NotificationsSection> renders heading", () => {
it("<NotificationsSection> renders the single reminder-types home", () => {
const html = render(<NotificationsSection />);
// v1.4.33 IW7 — section renamed from "Notifications" to
// "Notification channels" so it doesn't collide with the inbox at
// `/notifications` ("Notifications").
expect(html).toContain("Notification channels");
// v1.18.0 (S4) — one clean heading (sr-only h1 + visible description),
// no stacked breadcrumb. The screen is the single module-gated home for
// reminder TYPES.
expect(html).toContain("settings-section-notifications-title");
// The old stacked breadcrumb middle crumb is gone.
expect(html).not.toContain("Notification channels");
// A single concise pointer to the channels (now under Integrations).
expect(html).toContain('href="/settings/integrations"');
expect(html).toContain('data-slot="notifications-channels-cross-link"');
// The mood + coach cards fail OPEN (mock user carries no module map),
// so both reminder-type cards render.
expect(html).toContain('id="mood-reminder"');
expect(html).toContain('id="coach-nudge"');
expect(html).toContain('id="low-stock"');
// No raw i18n key leaks past the provider.
expect(html).not.toContain("settings.sections.notifications.");
});
it("<DashboardSection> renders heading and embeds the layout customizer", () => {
@@ -1,72 +1,63 @@
"use client";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { CoachNudgeCard } from "@/components/settings/coach-nudge-card";
import { LowStockCard } from "@/components/settings/low-stock-card";
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section";
/**
* `<NotificationsSection>` — Settings → Notifications.
* `<NotificationsSection>` — Settings → Notifications ("Benachrichtigungen").
*
* v1.18.0 (S3) — the delivery CHANNELS (Telegram / ntfy / Web Push / Webhook /
* Email + the live per-channel status surface) moved to Settings →
* Integrations → Channels: a channel is a delivery provider, the same family as
* a connected device, not a notification preference. What stays here is the
* reminder-TYPE content (which events fire): mood reminder, medication
* low-stock runway, proactive Coach nudge. S4 will consolidate these onto the
* Reminders hub.
* v1.18.0 (S4) — the single module-gated home for reminder TYPES. Earlier the
* settings carried two near-identical entries: this Notifications screen and a
* separate "Erinnerungen" hub that only deep-linked into the canonical editors
* ("doppelt gemoppelt"). The hub is gone (its route 301-redirects here); the
* reminder TYPES now live centrally on this one screen.
*
* Each reminder-type card is shown ONLY when its module is enabled, read from
* the resolved `useAuth().user.modules` map (the same `/auth/me` map nav and
* Insights pills gate off). Mood → `mood`, Coach nudge → `coach`. Low-stock
* runway maps to the medications CORE domain, so it is always shown. Vorsorge
* (preventive-care) reminders are not a toggleable module, so they are always
* shown. The gate fails OPEN: a missing key reads as enabled, so a stale
* `/me` payload never blanks a card.
*
* Delivery CHANNELS (Telegram / ntfy / Web Push / Webhook / Email) are a
* different concept — a channel is a delivery provider, the same family as a
* connected device — and live under Settings → Integrations → Channels (moved
* there in S3). A single concise pointer below the heading links to them.
*/
export function NotificationsSection() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const { isAuthenticated, user } = useAuth();
// v1.18.0 (S4) — module-gated per-type visibility. Fail OPEN (`!== false`)
// so a stale `/me` payload without the module map keeps every card visible.
const moodEnabled = user?.modules?.mood !== false;
const coachEnabled = user?.modules?.coach !== false;
return (
<section
aria-labelledby="settings-section-notifications-title"
className="space-y-6"
>
{/* v1.4.33 IW7 — disambiguate this settings screen from the inbox at
`/notifications`. The crumb spells out where the user is and offers a
one-tap jump to the inbox. */}
<nav
aria-label={t("nav.breadcrumb")}
className="text-muted-foreground text-xs"
>
<ol className="flex items-center gap-1">
<li>{t("notifications.breadcrumbSettings")}</li>
<li aria-hidden="true">
<ChevronRight className="h-3 w-3" />
</li>
<li className="text-foreground font-medium">
{t("notifications.breadcrumbChannels")}
</li>
<li aria-hidden="true">
<ChevronRight className="h-3 w-3" />
</li>
<li>
<Link
href="/notifications"
className="hover:text-foreground underline-offset-2 hover:underline"
>
{t("notifications.breadcrumbInbox")}
</Link>
</li>
</ol>
</nav>
<header className="space-y-1">
{/* sr-only h1 — the visible section title is painted by the settings
shell nav; the in-page heading mirrors every other settings
section's scaffold so the screen reads as one of the family. */}
<h1 id="settings-section-notifications-title" className="sr-only">
{t("settings.sections.notifications.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.notifications.description")}
</p>
{/* v1.18.0 (S3) — the delivery channels now live under Integrations.
Cross-link so a user looking for "where do I connect Telegram"
finds them in one tap. */}
{/* One concise pointer to the delivery channels (now under
Integrations) and one to the notification inbox — not a duplicated
section, just where-to-go links. */}
<p className="text-muted-foreground text-xs">
{t("settings.sections.notifications.channelsHint")}{" "}
<Link
@@ -76,20 +67,41 @@ export function NotificationsSection() {
>
{t("settings.sections.notifications.channelsHintLink")}
</Link>
{" · "}
<Link
href="/notifications"
className="text-primary underline underline-offset-2"
data-slot="notifications-inbox-cross-link"
>
{t("settings.sections.notifications.inboxLink")}
</Link>
</p>
</header>
<div id="mood-reminder" className="scroll-mt-28">
<MoodReminderCard isAuthenticated={isAuthenticated} />
</div>
{/* v1.16.11 — medication low-stock runway threshold. */}
{/* Vorsorge (preventive-care) reminders — always shown; not a
toggleable module. The canonical in-place editor, embedded here so
"what gets reminded and when" reads as one screen. */}
<VorsorgeSection enabled={isAuthenticated} />
{/* Mood check-in reminder — only when the mood module is enabled. */}
{moodEnabled ? (
<div id="mood-reminder" className="scroll-mt-28">
<MoodReminderCard isAuthenticated={isAuthenticated} />
</div>
) : null}
{/* Medication low-stock runway — medications is a CORE domain, so this
is always shown. */}
<div id="low-stock" className="scroll-mt-28">
<LowStockCard isAuthenticated={isAuthenticated} />
</div>
{/* v1.15.20 — proactive Coach nudge opt-out. */}
<div id="coach-nudge" className="scroll-mt-28">
<CoachNudgeCard isAuthenticated={isAuthenticated} />
</div>
{/* Proactive Coach nudge — only when the coach module is enabled. */}
{coachEnabled ? (
<div id="coach-nudge" className="scroll-mt-28">
<CoachNudgeCard isAuthenticated={isAuthenticated} />
</div>
) : null}
</section>
);
}