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

+3 -32
View File
@@ -3871,9 +3871,10 @@
},
"notifications": {
"title": "Benachrichtigungen",
"description": "Welche Erinnerungen ausgelöst werden. Die Zustellkanäle liegen unter Integrationen.",
"description": "Alle Erinnerungen an einem Ort — was wann erinnert wird. Die Zustellkanäle liegen unter Integrationen.",
"channelsHint": "Telegram, ntfy, Web-Push, Webhook und E-Mail richtest du jetzt unter",
"channelsHintLink": "Integrationen → Kanäle"
"channelsHintLink": "Integrationen → Kanäle",
"inboxLink": "Posteingang öffnen"
},
"layout": {
"title": "Layout & Personalisierung",
@@ -4106,36 +4107,6 @@
"title": "Stimmungs-Tags",
"description": "Gruppen, Tags und Reihenfolge der Tag-Auswahl."
},
"reminders": {
"title": "Erinnerungen & Benachrichtigungen",
"description": "Ein Ort für jede Erinnerung — woran und wann erinnert wird — und die Kanäle, die sie zustellen.",
"categoriesGroup": "Erinnerungen",
"channelsGroup": "Zustellkanäle",
"medication": {
"title": "Medikamenten-Erinnerungen",
"description": "Einnahmezeiten und Zeitfenster, je Medikament eingestellt."
},
"vorsorge": {
"title": "Vorsorge",
"description": "Wiederkehrende Untersuchungen und Messungen, inklusive Ort."
},
"mood": {
"title": "Stimmungs-Check-in",
"description": "Eine tägliche Erinnerung, dein Befinden zu erfassen."
},
"lowStock": {
"title": "Reichweite bei Restbestand",
"description": "Werde gewarnt, bevor ein Medikament zur Neige geht."
},
"coach": {
"title": "Coach-Hinweis",
"description": "Proaktive Impulse vom KI-Coach."
},
"channels": {
"title": "Benachrichtigungskanäle",
"description": "APNs, Telegram, ntfy, Web Push, Webhook und E-Mail."
}
},
"modules": {
"title": "Module",
"saved": "Gespeichert.",
+3 -32
View File
@@ -3871,9 +3871,10 @@
},
"notifications": {
"title": "Notifications",
"description": "Which reminders fire. The delivery channels live under Integrations.",
"description": "Every reminder in one place — what gets reminded and when. The delivery channels live under Integrations.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook, and Email are now set up under",
"channelsHintLink": "Integrations → Channels"
"channelsHintLink": "Integrations → Channels",
"inboxLink": "Open the inbox"
},
"layout": {
"title": "Layout & Personalization",
@@ -4106,36 +4107,6 @@
"title": "Mood tags",
"description": "Groups, tags, and order of the mood picker."
},
"reminders": {
"title": "Reminders & Notifications",
"description": "One home for every reminder — what gets reminded and when — plus the channels that deliver them.",
"categoriesGroup": "Reminders",
"channelsGroup": "Delivery channels",
"medication": {
"title": "Medication reminders",
"description": "Dose times and windows, configured on each medication."
},
"vorsorge": {
"title": "Preventive care",
"description": "Recurring check-ups and measurements, with where to go."
},
"mood": {
"title": "Mood check-in",
"description": "A daily nudge to log how you feel."
},
"lowStock": {
"title": "Low-stock runway",
"description": "Get warned before a medication runs out."
},
"coach": {
"title": "Coach nudge",
"description": "Proactive prompts from the AI coach."
},
"channels": {
"title": "Notification channels",
"description": "APNs, Telegram, ntfy, Web Push, Webhook, and Email."
}
},
"modules": {
"title": "Modules",
"saved": "Saved.",
+3 -32
View File
@@ -3871,9 +3871,10 @@
},
"notifications": {
"title": "Notificaciones",
"description": "Qué recordatorios se activan. Los canales de entrega están en Integraciones.",
"description": "Todos los recordatorios en un solo lugar: qué se recuerda y cuándo. Los canales de entrega están en Integraciones.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook y correo se configuran ahora en",
"channelsHintLink": "Integraciones → Canales"
"channelsHintLink": "Integraciones → Canales",
"inboxLink": "Abrir la bandeja de entrada"
},
"layout": {
"title": "Diseño y personalización",
@@ -4106,36 +4107,6 @@
"title": "Etiquetas de ánimo",
"description": "Grupos, etiquetas y orden del selector de ánimo."
},
"reminders": {
"title": "Recordatorios y notificaciones",
"description": "Un único lugar para cada recordatorio — qué se recuerda y cuándo — y los canales que lo entregan.",
"categoriesGroup": "Recordatorios",
"channelsGroup": "Canales de entrega",
"medication": {
"title": "Recordatorios de medicación",
"description": "Horas y ventanas de dosis, configuradas en cada medicamento."
},
"vorsorge": {
"title": "Cuidado preventivo",
"description": "Revisiones y mediciones periódicas, con el lugar al que acudir."
},
"mood": {
"title": "Registro de ánimo",
"description": "Un aviso diario para registrar cómo te sientes."
},
"lowStock": {
"title": "Margen por bajo stock",
"description": "Recibe un aviso antes de que se acabe un medicamento."
},
"coach": {
"title": "Sugerencia del coach",
"description": "Indicaciones proactivas del coach de IA."
},
"channels": {
"title": "Canales de notificación",
"description": "APNs, Telegram, ntfy, Web Push, Webhook y correo."
}
},
"modules": {
"title": "Módulos",
"saved": "Guardado.",
+3 -32
View File
@@ -3871,9 +3871,10 @@
},
"notifications": {
"title": "Notifications",
"description": "Quels rappels se déclenchent. Les canaux de diffusion se trouvent dans Intégrations.",
"description": "Tous les rappels au même endroit — quoi et quand. Les canaux de diffusion se trouvent dans Intégrations.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook et e-mail se configurent désormais dans",
"channelsHintLink": "Intégrations → Canaux"
"channelsHintLink": "Intégrations → Canaux",
"inboxLink": "Ouvrir la boîte de réception"
},
"layout": {
"title": "Disposition et personnalisation",
@@ -4106,36 +4107,6 @@
"title": "Étiquettes d’humeur",
"description": "Groupes, étiquettes et ordre du sélecteur d’humeur."
},
"reminders": {
"title": "Rappels et notifications",
"description": "Un seul endroit pour chaque rappel — quoi et quand — ainsi que les canaux qui les délivrent.",
"categoriesGroup": "Rappels",
"channelsGroup": "Canaux de diffusion",
"medication": {
"title": "Rappels de médication",
"description": "Heures et fenêtres de prise, configurées sur chaque médicament."
},
"vorsorge": {
"title": "Prévention",
"description": "Bilans et mesures récurrents, avec le lieu où se rendre."
},
"mood": {
"title": "Suivi de l'humeur",
"description": "Un rappel quotidien pour noter votre ressenti."
},
"lowStock": {
"title": "Marge de stock faible",
"description": "Soyez averti avant qu'un médicament ne s'épuise."
},
"coach": {
"title": "Suggestion du coach",
"description": "Invitations proactives du coach IA."
},
"channels": {
"title": "Canaux de notification",
"description": "APNs, Telegram, ntfy, Web Push, Webhook et e-mail."
}
},
"modules": {
"title": "Modules",
"saved": "Enregistré.",
+3 -32
View File
@@ -3871,9 +3871,10 @@
},
"notifications": {
"title": "Notifiche",
"description": "Quali promemoria vengono attivati. I canali di recapito si trovano in Integrazioni.",
"description": "Tutti i promemoria in un unico posto — cosa e quando. I canali di recapito si trovano in Integrazioni.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook ed e-mail ora si configurano in",
"channelsHintLink": "Integrazioni → Canali"
"channelsHintLink": "Integrazioni → Canali",
"inboxLink": "Apri la posta in arrivo"
},
"layout": {
"title": "Layout e personalizzazione",
@@ -4106,36 +4107,6 @@
"title": "Tag dell’umore",
"description": "Gruppi, tag e ordine del selettore dell’umore."
},
"reminders": {
"title": "Promemoria e notifiche",
"description": "Un'unica sede per ogni promemoria — cosa ricordare e quando — e i canali che li recapitano.",
"categoriesGroup": "Promemoria",
"channelsGroup": "Canali di consegna",
"medication": {
"title": "Promemoria dei farmaci",
"description": "Orari e finestre delle dosi, impostati su ogni farmaco."
},
"vorsorge": {
"title": "Prevenzione",
"description": "Controlli e misurazioni ricorrenti, con il luogo dove recarsi."
},
"mood": {
"title": "Check-in dell'umore",
"description": "Un promemoria quotidiano per registrare come ti senti."
},
"lowStock": {
"title": "Autonomia scorte",
"description": "Ricevi un avviso prima che un farmaco finisca."
},
"coach": {
"title": "Suggerimento del coach",
"description": "Stimoli proattivi dal coach IA."
},
"channels": {
"title": "Canali di notifica",
"description": "APNs, Telegram, ntfy, Web Push, Webhook ed e-mail."
}
},
"modules": {
"title": "Moduli",
"saved": "Salvato.",
+3 -32
View File
@@ -3871,9 +3871,10 @@
},
"notifications": {
"title": "Powiadomienia",
"description": "Które przypomnienia są uruchamiane. Kanały dostarczania znajdują się w Integracjach.",
"description": "Wszystkie przypomnienia w jednym miejscu — co i kiedy. Kanały dostarczania znajdują się w Integracjach.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook i e-mail konfigurujesz teraz w",
"channelsHintLink": "Integracje → Kanały"
"channelsHintLink": "Integracje → Kanały",
"inboxLink": "Otwórz skrzynkę odbiorczą"
},
"layout": {
"title": "Układ i personalizacja",
@@ -4106,36 +4107,6 @@
"title": "Tagi nastroju",
"description": "Grupy, tagi i kolejność w wyborze nastroju."
},
"reminders": {
"title": "Przypomnienia i powiadomienia",
"description": "Jedno miejsce dla każdego przypomnienia — o czym i kiedy — oraz kanały, które je dostarczają.",
"categoriesGroup": "Przypomnienia",
"channelsGroup": "Kanały dostarczania",
"medication": {
"title": "Przypomnienia o lekach",
"description": "Godziny i okna dawek, ustawiane przy każdym leku."
},
"vorsorge": {
"title": "Profilaktyka",
"description": "Cykliczne badania i pomiary wraz z miejscem, gdzie się udać."
},
"mood": {
"title": "Zapis nastroju",
"description": "Codzienne przypomnienie, by zapisać samopoczucie."
},
"lowStock": {
"title": "Zapas na wyczerpaniu",
"description": "Otrzymaj ostrzeżenie, zanim lek się skończy."
},
"coach": {
"title": "Podpowiedź trenera",
"description": "Proaktywne wskazówki od trenera AI."
},
"channels": {
"title": "Kanały powiadomień",
"description": "APNs, Telegram, ntfy, Web Push, Webhook i e-mail."
}
},
"modules": {
"title": "Moduły",
"saved": "Zapisano.",
@@ -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>
);
}