fix(notifications): repoint channel CTA, add error/loading primitives, cross-link surfaces

- The 'no channels' CTA now points at Settings → Integrations#channels
  (where channels are actually configured since v1.25.7) instead of the
  reminder-types screen, which had become a dead-end.
- A failed load renders the shared QueryErrorCard with Retry; the loading
  frame reserves a matrix skeleton instead of a lone spinner.
- The globally-disabled hint reads as a full sentence rather than the bare
  '(Disabled)' inline tag.
- The three notification surfaces (per-event matrix, reminder types,
  delivery channels) now cross-link each other.
- noChannels copy names its destination.
This commit is contained in:
Marc-André Bombeck committed 2026-07-08 23:39:59 +02:00
1 parent 2dc9947de2
commit 2e84498cf3
10 files changed
+273 -37

No files matched your search

+10 -4
View File
@@ -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": {
+10 -4
View File
@@ -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": {
+10 -4
View File
@@ -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": {
+10 -4
View File
@@ -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": {
+10 -4
View File
@@ -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": {
+10 -4
View File
@@ -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": {
@@ -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(
<I18nProvider initialLocale="en">
<NotificationsPage />
</I18nProvider>,
);
}
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",
);
});
});
+58 -13
View File
@@ -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<PreferencesData>("/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 = (
<>
<Button asChild variant="outline" size="sm">
<Link href="/settings/notifications">
{t("notifications.reminderSettingsLink")}
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/settings/integrations#channels">
{t("notifications.channelsLink")}
</Link>
</Button>
</>
);
if (authLoading || isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
<div className="space-y-6">
<PageHeader
title={t("notifications.title")}
description={t("notifications.subtitle")}
/>
{/* Skeleton mirrors the event-row matrix so the auth-resolving frame
reserves layout instead of flashing a lone spinner. */}
<div
data-slot="notifications-loading"
className="bg-card border-border overflow-hidden rounded-xl border"
>
{[0, 1, 2, 3, 4].map((i) => (
<div
key={i}
className="border-border flex items-center justify-between gap-4 border-b p-4 last:border-b-0"
>
<div className="min-w-0 flex-1 space-y-2">
<Skeleton className="h-4 w-40 rounded" />
<Skeleton className="h-3 w-56 rounded" />
</div>
<Skeleton className="h-5 w-9 shrink-0 rounded-full" />
</div>
))}
</div>
</div>
);
}
@@ -149,12 +191,10 @@ export default function NotificationsPage() {
title={t("notifications.title")}
description={t("notifications.subtitle")}
/>
<div
role="alert"
className="border-destructive/40 bg-destructive/10 text-destructive rounded-lg border p-4 text-sm"
>
{t("notifications.loadError")}
</div>
<QueryErrorCard
description={t("notifications.loadError")}
onRetry={() => refetch()}
/>
</div>
);
}
@@ -166,6 +206,7 @@ export default function NotificationsPage() {
<PageHeader
title={t("notifications.title")}
description={t("notifications.subtitle")}
actions={headerActions}
/>
<div className="bg-card border-border max-w-2xl rounded-xl border p-6">
<div className="flex flex-col items-center gap-4 py-8 text-center">
@@ -173,8 +214,10 @@ export default function NotificationsPage() {
<p className="text-muted-foreground text-sm">
{t("notifications.noChannels")}
</p>
{/* Channel setup lives in Settings → Integrations since v1.25.7;
point the CTA at that anchor, not the reminder-types screen. */}
<Button asChild variant="outline">
<Link href="/settings/notifications">
<Link href="/settings/integrations#channels">
<Settings className="h-4 w-4" />
{t("notifications.goToSettings")}
</Link>
@@ -190,6 +233,7 @@ export default function NotificationsPage() {
<PageHeader
title={t("notifications.title")}
description={t("notifications.subtitle")}
actions={headerActions}
/>
{/* Desktop: table layout */}
@@ -321,12 +365,13 @@ export default function NotificationsPage() {
})}
</div>
{/* 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) && (
<div className="flex items-start gap-2 rounded-lg p-3 text-sm">
<AlertCircle className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
<p className="text-muted-foreground">
{t("notifications.channelDisabled")}
{t("notifications.someChannelsGloballyDisabled")}
</p>
</div>
)}
@@ -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. */}
<div>
<Button asChild variant="outline" size="sm">
<Link href="/notifications">
{t("settings.sections.integrations.matrixLink")}
</Link>
</Button>
</div>
<NotificationChannelsPanel />
</section>
</div>
@@ -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";
/**
* `<NotificationsSection>` — Settings → Notifications ("Benachrichtigungen").
@@ -44,6 +46,21 @@ export function NotificationsSection() {
return (
<div className="space-y-10">
{/* Cross-links to the other two notification surfaces so this screen
names them — the per-event matrix and the delivery channels. */}
<div className="flex flex-wrap gap-2" data-slot="notifications-crosslinks">
<Button asChild variant="outline" size="sm">
<Link href="/notifications">
{t("settings.sections.notifications.matrixLink")}
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/settings/integrations#channels">
{t("settings.sections.notifications.channelsLink")}
</Link>
</Button>
</div>
{/* 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. */}