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. */} +
+ {[0, 1, 2, 3, 4].map((i) => ( +
+
+ + +
+ +
+ ))} +
); } @@ -149,12 +191,10 @@ export default function NotificationsPage() { title={t("notifications.title")} description={t("notifications.subtitle")} /> -
- {t("notifications.loadError")} -
+ refetch()} + />
); } @@ -166,6 +206,7 @@ export default function NotificationsPage() {
@@ -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) && (

- {t("notifications.channelDisabled")} + {t("notifications.someChannelsGloballyDisabled")}

)} 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. */}