diff --git a/messages/de.json b/messages/de.json
index 5073c6e7b..42965e6ba 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4055,14 +4055,17 @@
"connectionsHeading": "Verbindungen",
"connectionsDescription": "Datenquellen und Geräte verbinden und verwalten.",
"channelsHeading": "Zustellkanäle",
- "channelsDescription": "Wohin Erinnerungen und Hinweise zugestellt werden."
+ "channelsDescription": "Wohin Erinnerungen und Hinweise zugestellt werden.",
+ "matrixLink": "Benachrichtigungen je Kanal"
},
"notifications": {
"title": "Benachrichtigungen",
"description": "Alle Erinnerungen an einem Ort — was wann erinnert wird und wohin es zugestellt wird.",
"subtitle": "Wähle, welche Erinnerungen du erhältst und wohin sie zugestellt werden.",
"remindersHeading": "Erinnerungen",
- "remindersDescription": "Was du erhältst."
+ "remindersDescription": "Was du erhältst.",
+ "matrixLink": "Benachrichtigungen je Kanal",
+ "channelsLink": "Zustellkanäle"
},
"layout": {
"title": "Darstellung",
@@ -6268,7 +6271,7 @@
"eventColumn": "Ereignis",
"subtitle": "Wähle aus, welche Benachrichtigungen du über welchen Kanal erhalten möchtest.",
"loadError": "Benachrichtigungseinstellungen konnten nicht geladen werden.",
- "noChannels": "Konfiguriere zuerst einen Benachrichtigungskanal in den Einstellungen.",
+ "noChannels": "Konfiguriere zuerst einen Benachrichtigungskanal unter Einstellungen → Integrationen.",
"goToSettings": "Zu den Kanal-Einstellungen",
"channelDisabled": "Deaktiviert",
"admin": {
@@ -6379,7 +6382,10 @@
"newDeviceBody": "Eine neue Anmeldung von {device} ({location}). Falls du das nicht warst, ändere sofort dein Passwort.",
"unknownLocation": "unbekannter Ort"
},
- "loginRequired": "Bitte melde dich an, um Benachrichtigungen zu verwalten."
+ "loginRequired": "Bitte melde dich an, um Benachrichtigungen zu verwalten.",
+ "someChannelsGloballyDisabled": "Ein oder mehrere Kanäle sind in den Einstellungen deaktiviert und erhalten keine Benachrichtigungen.",
+ "reminderSettingsLink": "Erinnerungseinstellungen",
+ "channelsLink": "Kanäle"
},
"i18n": {
"maintainershipBanner": {
diff --git a/messages/en.json b/messages/en.json
index e85e5c5fc..0deafe73f 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4055,14 +4055,17 @@
"connectionsHeading": "Connections",
"connectionsDescription": "Connect and manage your data sources and devices.",
"channelsHeading": "Delivery channels",
- "channelsDescription": "Where reminders and alerts are delivered."
+ "channelsDescription": "Where reminders and alerts are delivered.",
+ "matrixLink": "Per-channel notifications"
},
"notifications": {
"title": "Notifications",
"description": "Every reminder in one place — what gets reminded and when, and where it's delivered.",
"subtitle": "Choose which reminders you receive and where they're delivered.",
"remindersHeading": "Reminders",
- "remindersDescription": "What you receive."
+ "remindersDescription": "What you receive.",
+ "matrixLink": "Per-channel notifications",
+ "channelsLink": "Delivery channels"
},
"layout": {
"title": "Appearance",
@@ -6268,7 +6271,7 @@
"eventColumn": "Event",
"subtitle": "Choose which notifications you want to receive through which channel.",
"loadError": "Notification preferences could not be loaded.",
- "noChannels": "Configure a notification channel in Settings first.",
+ "noChannels": "Configure a notification channel in Settings → Integrations first.",
"goToSettings": "Go to channel settings",
"channelDisabled": "Disabled",
"admin": {
@@ -6379,7 +6382,10 @@
"newDeviceBody": "A new sign-in from {device} ({location}). If this wasn't you, change your password immediately.",
"unknownLocation": "unknown location"
},
- "loginRequired": "Please sign in to manage notifications."
+ "loginRequired": "Please sign in to manage notifications.",
+ "someChannelsGloballyDisabled": "One or more channels are turned off in Settings and won’t receive notifications.",
+ "reminderSettingsLink": "Reminder settings",
+ "channelsLink": "Channels"
},
"i18n": {
"maintainershipBanner": {
diff --git a/messages/es.json b/messages/es.json
index 80d3c7b3c..bf2109424 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4055,14 +4055,17 @@
"connectionsHeading": "Conexiones",
"connectionsDescription": "Conecta y gestiona tus fuentes de datos y dispositivos.",
"channelsHeading": "Canales de entrega",
- "channelsDescription": "Dónde se entregan los recordatorios y avisos."
+ "channelsDescription": "Dónde se entregan los recordatorios y avisos.",
+ "matrixLink": "Notificaciones por canal"
},
"notifications": {
"title": "Notificaciones",
"description": "Todos los recordatorios en un solo lugar: qué se recuerda y cuándo, y dónde se entrega.",
"subtitle": "Elige qué recordatorios recibes y dónde se entregan.",
"remindersHeading": "Recordatorios",
- "remindersDescription": "Lo que recibes."
+ "remindersDescription": "Lo que recibes.",
+ "matrixLink": "Notificaciones por canal",
+ "channelsLink": "Canales de entrega"
},
"layout": {
"title": "Apariencia",
@@ -6268,7 +6271,7 @@
"eventColumn": "Evento",
"subtitle": "Elige qué notificaciones recibes y por qué canal.",
"loadError": "No se han podido cargar los ajustes de notificación.",
- "noChannels": "Configura primero un canal de notificación en los ajustes.",
+ "noChannels": "Configura primero un canal de notificación en Ajustes → Integraciones.",
"goToSettings": "Ir a los ajustes del canal",
"channelDisabled": "Desactivado",
"admin": {
@@ -6379,7 +6382,10 @@
"newDeviceBody": "Un nuevo inicio de sesión desde {device} ({location}). Si no fuiste tú, cambia tu contraseña de inmediato.",
"unknownLocation": "ubicación desconocida"
},
- "loginRequired": "Inicia sesión para gestionar las notificaciones."
+ "loginRequired": "Inicia sesión para gestionar las notificaciones.",
+ "someChannelsGloballyDisabled": "Uno o más canales están desactivados en los ajustes y no recibirán notificaciones.",
+ "reminderSettingsLink": "Ajustes de recordatorios",
+ "channelsLink": "Canales"
},
"i18n": {
"maintainershipBanner": {
diff --git a/messages/fr.json b/messages/fr.json
index 5bd017305..3a211914e 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4055,14 +4055,17 @@
"connectionsHeading": "Connexions",
"connectionsDescription": "Connectez et gérez vos sources de données et appareils.",
"channelsHeading": "Canaux de diffusion",
- "channelsDescription": "Où sont envoyés les rappels et alertes."
+ "channelsDescription": "Où sont envoyés les rappels et alertes.",
+ "matrixLink": "Notifications par canal"
},
"notifications": {
"title": "Notifications",
"description": "Tous les rappels au même endroit — quoi et quand, et où c'est envoyé.",
"subtitle": "Choisissez les rappels que vous recevez et où ils sont envoyés.",
"remindersHeading": "Rappels",
- "remindersDescription": "Ce que vous recevez."
+ "remindersDescription": "Ce que vous recevez.",
+ "matrixLink": "Notifications par canal",
+ "channelsLink": "Canaux de diffusion"
},
"layout": {
"title": "Apparence",
@@ -6268,7 +6271,7 @@
"eventColumn": "Événement",
"subtitle": "Choisis quelles notifications tu reçois et par quel canal.",
"loadError": "Impossible de charger les paramètres de notification.",
- "noChannels": "Configure d’abord un canal de notification dans les paramètres.",
+ "noChannels": "Configure d’abord un canal de notification dans Paramètres → Intégrations.",
"goToSettings": "Aller aux paramètres du canal",
"channelDisabled": "Désactivé",
"admin": {
@@ -6379,7 +6382,10 @@
"newDeviceBody": "Une nouvelle connexion depuis {device} ({location}). Si ce n'était pas vous, changez votre mot de passe immédiatement.",
"unknownLocation": "lieu inconnu"
},
- "loginRequired": "Connecte-toi pour gérer les notifications."
+ "loginRequired": "Connecte-toi pour gérer les notifications.",
+ "someChannelsGloballyDisabled": "Un ou plusieurs canaux sont désactivés dans les paramètres et ne recevront pas de notifications.",
+ "reminderSettingsLink": "Réglages des rappels",
+ "channelsLink": "Canaux"
},
"i18n": {
"maintainershipBanner": {
diff --git a/messages/it.json b/messages/it.json
index 933289bb6..51dee5cd3 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4055,14 +4055,17 @@
"connectionsHeading": "Connessioni",
"connectionsDescription": "Collega e gestisci le tue fonti di dati e i dispositivi.",
"channelsHeading": "Canali di recapito",
- "channelsDescription": "Dove vengono recapitati promemoria e avvisi."
+ "channelsDescription": "Dove vengono recapitati promemoria e avvisi.",
+ "matrixLink": "Notifiche per canale"
},
"notifications": {
"title": "Notifiche",
"description": "Tutti i promemoria in un unico posto — cosa e quando, e dove vengono recapitati.",
"subtitle": "Scegli quali promemoria ricevere e dove vengono recapitati.",
"remindersHeading": "Promemoria",
- "remindersDescription": "Ciò che ricevi."
+ "remindersDescription": "Ciò che ricevi.",
+ "matrixLink": "Notifiche per canale",
+ "channelsLink": "Canali di recapito"
},
"layout": {
"title": "Aspetto",
@@ -6268,7 +6271,7 @@
"eventColumn": "Evento",
"subtitle": "Scegli quali notifiche ricevere e su quale canale.",
"loadError": "Impossibile caricare le impostazioni di notifica.",
- "noChannels": "Configura prima un canale di notifica nelle impostazioni.",
+ "noChannels": "Configura prima un canale di notifica in Impostazioni → Integrazioni.",
"goToSettings": "Vai alle impostazioni del canale",
"channelDisabled": "Disabilitato",
"admin": {
@@ -6379,7 +6382,10 @@
"newDeviceBody": "Un nuovo accesso da {device} ({location}). Se non sei stato tu, cambia subito la password.",
"unknownLocation": "posizione sconosciuta"
},
- "loginRequired": "Accedi per gestire le notifiche."
+ "loginRequired": "Accedi per gestire le notifiche.",
+ "someChannelsGloballyDisabled": "Uno o più canali sono disattivati nelle impostazioni e non riceveranno notifiche.",
+ "reminderSettingsLink": "Impostazioni promemoria",
+ "channelsLink": "Canali"
},
"i18n": {
"maintainershipBanner": {
diff --git a/messages/pl.json b/messages/pl.json
index fc43f1599..50016525c 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4055,14 +4055,17 @@
"connectionsHeading": "Połączenia",
"connectionsDescription": "Połącz i zarządzaj źródłami danych oraz urządzeniami.",
"channelsHeading": "Kanały dostarczania",
- "channelsDescription": "Gdzie dostarczane są przypomnienia i powiadomienia."
+ "channelsDescription": "Gdzie dostarczane są przypomnienia i powiadomienia.",
+ "matrixLink": "Powiadomienia wg kanału"
},
"notifications": {
"title": "Powiadomienia",
"description": "Wszystkie przypomnienia w jednym miejscu — co i kiedy oraz gdzie są dostarczane.",
"subtitle": "Wybierz, które przypomnienia otrzymujesz i gdzie są dostarczane.",
"remindersHeading": "Przypomnienia",
- "remindersDescription": "Co otrzymujesz."
+ "remindersDescription": "Co otrzymujesz.",
+ "matrixLink": "Powiadomienia wg kanału",
+ "channelsLink": "Kanały dostarczania"
},
"layout": {
"title": "Wygląd",
@@ -6268,7 +6271,7 @@
"eventColumn": "Zdarzenie",
"subtitle": "Wybierz, które powiadomienia chcesz otrzymywać i którym kanałem.",
"loadError": "Nie udało się załadować ustawień powiadomień.",
- "noChannels": "Najpierw skonfiguruj kanał powiadomień w ustawieniach.",
+ "noChannels": "Najpierw skonfiguruj kanał powiadomień w Ustawienia → Integracje.",
"goToSettings": "Przejdź do ustawień kanału",
"channelDisabled": "Wyłączone",
"admin": {
@@ -6379,7 +6382,10 @@
"newDeviceBody": "Nowe logowanie z {device} ({location}). Jeśli to nie Ty, natychmiast zmień hasło.",
"unknownLocation": "nieznana lokalizacja"
},
- "loginRequired": "Zaloguj się, aby zarządzać powiadomieniami."
+ "loginRequired": "Zaloguj się, aby zarządzać powiadomieniami.",
+ "someChannelsGloballyDisabled": "Co najmniej jeden kanał jest wyłączony w ustawieniach i nie będzie otrzymywać powiadomień.",
+ "reminderSettingsLink": "Ustawienia przypomnień",
+ "channelsLink": "Kanały"
},
"i18n": {
"maintainershipBanner": {
diff --git a/src/app/notifications/__tests__/page.test.tsx b/src/app/notifications/__tests__/page.test.tsx
new file mode 100644
index 000000000..4306c4522
--- /dev/null
+++ b/src/app/notifications/__tests__/page.test.tsx
@@ -0,0 +1,127 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+
+/**
+ * Notification-matrix page contract (QoL/UX audit 2026-07-08):
+ * - H2: the "no channels" CTA points at Settings → Integrations#channels,
+ * the surface that can actually add a channel (not the reminder-types
+ * screen, which no longer configures channels since v1.25.7).
+ * - M3: a failed load renders the shared `QueryErrorCard` with a Retry,
+ * never a bare alert box; the loading frame reserves a skeleton.
+ * - L1: the globally-disabled hint reads as a full sentence, not the bare
+ * "(Disabled)" inline tag.
+ * - M7: the page cross-links the other two notification surfaces.
+ */
+
+type AuthState = { isAuthenticated: boolean; isLoading: boolean };
+type QueryState = {
+ data: unknown;
+ isLoading: boolean;
+ isError: boolean;
+};
+
+let authState: AuthState = { isAuthenticated: true, isLoading: false };
+let queryState: QueryState = { data: undefined, isLoading: false, isError: false };
+
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => authState,
+}));
+
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: () => ({ ...queryState, refetch: vi.fn() }),
+ useMutation: () => ({ mutate: vi.fn(), isPending: false }),
+ useQueryClient: () => ({
+ cancelQueries: vi.fn(),
+ getQueryData: vi.fn(),
+ setQueryData: vi.fn(),
+ invalidateQueries: vi.fn(),
+ }),
+}));
+
+import NotificationsPage from "../page";
+
+function render() {
+ return renderToStaticMarkup(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ authState = { isAuthenticated: true, isLoading: false };
+ queryState = { data: undefined, isLoading: false, isError: false };
+});
+
+describe("NotificationsPage", () => {
+ it("M3: renders a skeleton while loading, not a lone spinner", () => {
+ queryState.isLoading = true;
+ const html = render();
+ expect(html).toContain('data-slot="notifications-loading"');
+ expect(html).not.toContain("animate-spin");
+ });
+
+ it("M3: renders the shared QueryErrorCard with a retry on load failure", () => {
+ queryState.isError = true;
+ const html = render();
+ expect(html).toContain('data-slot="query-error-card"');
+ expect(html).toContain("Notification preferences could not be loaded.");
+ });
+
+ it("H2 + L4: the no-channels CTA targets Settings → Integrations#channels", () => {
+ queryState.data = { channels: [], preferences: [], eventTypes: [] };
+ const html = render();
+ expect(html).toContain('href="/settings/integrations#channels"');
+ // The reminder-types screen no longer configures channels — never point there.
+ expect(html).not.toContain('href="/settings/notifications"><');
+ expect(html).toContain("Settings → Integrations");
+ });
+
+ it("M7: the matrix cross-links the reminder-types + channels surfaces", () => {
+ queryState.data = {
+ channels: [
+ {
+ id: "c1",
+ type: "telegram",
+ label: "Telegram",
+ enabled: true,
+ globallyEnabled: true,
+ },
+ ],
+ preferences: [],
+ eventTypes: ["MEDICATION_REMINDER"],
+ };
+ const html = render();
+ expect(html).toContain('href="/settings/notifications"');
+ expect(html).toContain('href="/settings/integrations#channels"');
+ });
+
+ it("L1: the globally-disabled hint is a full sentence, not the bare tag", () => {
+ queryState.data = {
+ channels: [
+ {
+ id: "c1",
+ type: "telegram",
+ label: "Telegram",
+ enabled: true,
+ globallyEnabled: true,
+ },
+ {
+ id: "c2",
+ type: "ntfy",
+ label: "ntfy",
+ enabled: true,
+ globallyEnabled: false,
+ },
+ ],
+ preferences: [],
+ eventTypes: ["MEDICATION_REMINDER"],
+ };
+ const html = render();
+ expect(html).toContain(
+ "won’t receive notifications",
+ );
+ });
+});
diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx
index 4078419e7..f5c64067d 100644
--- a/src/app/notifications/page.tsx
+++ b/src/app/notifications/page.tsx
@@ -7,8 +7,10 @@ import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/ui/page-header";
+import { Skeleton } from "@/components/ui/skeleton";
+import { QueryErrorCard } from "@/components/ui/query-error-card";
import Link from "next/link";
-import { Bell, Loader2, Settings, AlertCircle } from "lucide-react";
+import { Bell, Settings, AlertCircle } from "lucide-react";
import {
EVENT_DEFAULT_ENABLED,
type EventType,
@@ -52,7 +54,7 @@ export default function NotificationsPage() {
const { t } = useTranslations();
const queryClient = useQueryClient();
- const { data, isLoading, isError } = useQuery({
+ const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.notificationsPreferences(),
queryFn: async () => {
return apiGet("/api/notifications/preferences");
@@ -119,10 +121,50 @@ export default function NotificationsPage() {
return EVENT_DEFAULT_ENABLED[eventType as EventType] ?? true;
}
+ // Cross-links to the other two notification surfaces (reminder types +
+ // delivery channels) so the matrix never reads as the only place to manage
+ // notifications. Mirrored by back-links on those two settings screens.
+ const headerActions = (
+ <>
+
+
+ >
+ );
+
if (authLoading || isLoading) {
return (
-
-
+
+
+ {/* Skeleton mirrors the event-row matrix so the auth-resolving frame
+ reserves layout instead of flashing a lone spinner. */}
+
@@ -173,8 +214,10 @@ export default function NotificationsPage() {
{t("notifications.noChannels")}
+ {/* Channel setup lives in Settings → Integrations since v1.25.7;
+ point the CTA at that anchor, not the reminder-types screen. */}
- {/* Info hint about disabled channels */}
+ {/* Info hint about globally-disabled channels — a full sentence that
+ explains the state, not the bare "(Disabled)" inline tag. */}
{channels.some((ch) => !ch.globallyEnabled) && (
)}
diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx
index 21a537a54..4d9a34839 100644
--- a/src/components/settings/integrations-section.tsx
+++ b/src/components/settings/integrations-section.tsx
@@ -21,12 +21,14 @@
* `connections-panel.tsx` so existing unit-test imports keep resolving.
*/
+import Link from "next/link";
import { Link2, Send } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel";
import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel";
+import { Button } from "@/components/ui/button";
export {
parseOAuthOutcome,
@@ -65,6 +67,15 @@ export function IntegrationsSection() {
title={t("settings.sections.integrations.channelsHeading")}
description={t("settings.sections.integrations.channelsDescription")}
/>
+ {/* Names the per-event notification matrix so the three notification
+ surfaces (matrix, reminder types, channels) cross-reference. */}
+
+
+
diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx
index 8ba29af03..05389e73a 100644
--- a/src/components/settings/notifications-section.tsx
+++ b/src/components/settings/notifications-section.tsx
@@ -1,5 +1,6 @@
"use client";
+import Link from "next/link";
import { Bell } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
@@ -7,6 +8,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { LowStockCard } from "@/components/settings/low-stock-card";
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
+import { Button } from "@/components/ui/button";
/**
* `` — Settings → Notifications ("Benachrichtigungen").
@@ -44,6 +46,21 @@ export function NotificationsSection() {
return (
+ {/* Cross-links to the other two notification surfaces so this screen
+ names them — the per-event matrix and the delivery channels. */}
+
+
+
+
+
{/* Group 1 — Reminders ("what you receive"). Rendered only when at
least one reminder-type card is visible, so a fully-disabled
account never shows an empty heading. */}