From d2afcf49dff5844c2c13356a5d92cab52b2c054b Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 09:32:52 +0100 Subject: [PATCH] feat(notifications): add channel routing matrix, daily summary, and task watch button --- CHANGELOG.md | 4 + .../features/settings/UserSettingsView.tsx | 276 ++++++++++++++++-- .../UserSettingsView.alarms.test.tsx | 106 +++++++ .../src/features/triage/TaskDetailModal.tsx | 46 ++- .../triage/__tests__/TaskDetailModal.test.tsx | 19 +- frontend/src/lib/api.ts | 14 + frontend/src/lib/types.ts | 28 ++ 7 files changed, 461 insertions(+), 32 deletions(-) create mode 100644 frontend/src/features/settings/__tests__/UserSettingsView.alarms.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cf7027..d6e0fff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +### Added +- Kontoeinstellungen → Alarme: Kanal-Matrix (E-Mail / Discord) für „Fällige Aufgaben (sofort)“, „Freigabe erhalten“, „Geteilte Aufgabe erledigt“ und „Beobachtete Aufgaben“, tägliche Zusammenfassung mit Uhrzeit sowie Schalter zum Pausieren aller Benachrichtigungen und für eigene Änderungen an beobachteten Aufgaben. +- Aufgaben-Details: Knopf „Beobachten“ / „Nicht mehr beobachten“ (`POST/DELETE /tasks/{id}/watch`). + ### Changed - `/diktat` in Notizen: schreibt live mit (mehrzeiliger Text über den Wellen mit Zeitanzeige `mm:ss`), bricht im Browser-Modus bei Sprechpausen nicht mehr ab und schickt im Server-Modus alle ~8 Sekunden bzw. nach kurzen Sprechpausen Abschnitte zur Erkennung vorab. diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index 5ade494..c11cae9 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -17,7 +17,7 @@ import { Pencil, Copy, } from 'lucide-react'; -import { AuthStatus, User, UserTheme } from '../../lib/types'; +import { AuthStatus, NotificationEvents, NotificationSettings, User, UserTheme } from '../../lib/types'; import { ApiKeysPanel } from './ApiKeysPanel'; import { ServerBackupsCard } from './ServerBackupsCard'; import { saveBlob } from '../../lib/download'; @@ -47,6 +47,48 @@ interface UserSettingsViewProps { type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks'; +export function buildNotificationSettings(user: User): NotificationSettings { + const hasLegacy = Array.isArray(user.alarmChannels); + const dueEmailDefault = hasLegacy ? user.alarmChannels!.includes('email') : true; + const dueDiscordDefault = hasLegacy ? user.alarmChannels!.includes('discord') : true; + const s = user.notificationSettings; + return { + paused: Boolean(s?.paused), + includeOwnWatched: Boolean(s?.includeOwnWatched), + events: { + due: { + email: s?.events?.due?.email ?? dueEmailDefault, + discord: s?.events?.due?.discord ?? dueDiscordDefault, + }, + shared: { + email: s?.events?.shared?.email ?? true, + discord: s?.events?.shared?.discord ?? false, + }, + taskDone: { + email: s?.events?.taskDone?.email ?? false, + discord: s?.events?.taskDone?.discord ?? false, + }, + watched: { + email: s?.events?.watched?.email ?? true, + discord: s?.events?.watched?.discord ?? true, + }, + }, + dailySummary: { + enabled: s?.dailySummary?.enabled ?? false, + time: s?.dailySummary?.time || '07:00', + email: s?.dailySummary?.email ?? true, + discord: s?.dailySummary?.discord ?? false, + }, + }; +} + +const NOTIFICATION_EVENT_ROWS: { key: keyof NotificationEvents; label: string }[] = [ + { key: 'due', label: 'Fällige Aufgaben (sofort)' }, + { key: 'shared', label: 'Freigabe erhalten' }, + { key: 'taskDone', label: 'Geteilte Aufgabe erledigt' }, + { key: 'watched', label: 'Beobachtete Aufgaben' }, +]; + export const UserSettingsView: React.FC = ({ currentUser, activeTheme, @@ -211,6 +253,9 @@ export const UserSettingsView: React.FC = ({ const [alarmChannels, setAlarmChannels] = useState( Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : [] ); + const [notificationSettings, setNotificationSettings] = useState(() => + buildNotificationSettings(currentUser) + ); const [alarmEmail, setAlarmEmail] = useState(currentUser.alarmEmail || currentUser.email || ''); const [discordWebhookUrl, setDiscordWebhookUrl] = useState(currentUser.discordWebhookUrl || ''); const [customWebhookUrl, setCustomWebhookUrl] = useState(currentUser.customWebhookUrl || ''); @@ -226,6 +271,7 @@ export const UserSettingsView: React.FC = ({ setEmail(currentUser.email || ''); setAvatarUrl(currentUser.avatarUrl || ''); setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []); + setNotificationSettings(buildNotificationSettings(currentUser)); setAlarmEmail(currentUser.alarmEmail || currentUser.email || ''); setDiscordWebhookUrl(currentUser.discordWebhookUrl || ''); setCustomWebhookUrl(currentUser.customWebhookUrl || ''); @@ -238,6 +284,19 @@ export const UserSettingsView: React.FC = ({ ); }; + const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord', value: boolean) => { + setNotificationSettings((prev) => ({ + ...prev, + events: { + ...prev.events, + [eventKey]: { + ...prev.events[eventKey], + [channel]: value, + }, + }, + })); + }; + // --- Handlers: Avatar --- const handleAvatarFileSelected = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -324,14 +383,23 @@ export const UserSettingsView: React.FC = ({ setAlarmMsg(null); setSavingAlarms(true); try { + const nextAlarmChannels = [ + ...(notificationSettings.events.due.email ? ['email'] : []), + ...(notificationSettings.events.due.discord ? ['discord'] : []), + ...(alarmChannels.includes('webhook') ? ['webhook'] : []), + ]; const updated = await api.updateSettings({ - alarmChannels, + alarmChannels: nextAlarmChannels, alarmEmail: alarmEmail.trim(), discordWebhookUrl: discordWebhookUrl.trim(), customWebhookUrl: customWebhookUrl.trim(), notifyBeforeMinutes: Number(notifyBeforeMinutes), + notificationSettings, + }); + onUserUpdated({ + ...updated, + notificationSettings: updated.notificationSettings ?? notificationSettings, }); - onUserUpdated(updated); setAlarmMsg({ type: 'ok', text: 'Alarm-Kanäle und Vorwarnzeit erfolgreich gespeichert.' }); } catch (err: any) { setAlarmMsg({ type: 'err', text: err.message || 'Fehler beim Speichern der Alarme' }); @@ -1019,6 +1087,176 @@ export const UserSettingsView: React.FC = ({ )}
+ {/* Global Pause & Own Watched Toggles */} +
+
+
+

Alle Benachrichtigungen pausieren

+

+ Pausiert vorübergehend sämtliche E-Mail- und Discord-Benachrichtigungen. +

+
+ setNotificationSettings((prev) => ({ ...prev, paused: v }))} + title="Alle Benachrichtigungen pausieren" + /> +
+
+
+

+ Auch eigene Änderungen an beobachteten Aufgaben melden +

+

+ Standardmäßig wirst du nur über Änderungen anderer Personen benachrichtigt. +

+
+ setNotificationSettings((prev) => ({ ...prev, includeOwnWatched: v }))} + title="Auch eigene Änderungen an beobachteten Aufgaben melden" + /> +
+
+ + {/* Routing Matrix: Events x (E-Mail / Discord) */} + {(() => { + const hasDiscordWebhook = Boolean(discordWebhookUrl.trim()); + return ( + <> +
+
+ Ereignis + + + E-Mail + + + + Discord + +
+ +
+ {NOTIFICATION_EVENT_ROWS.map((row) => { + const routing = notificationSettings.events[row.key]; + return ( +
+ {row.label} +
+ setEventChannel(row.key, 'email', v)} + title={`${row.label} – E-Mail`} + /> +
+
+ setEventChannel(row.key, 'discord', v)} + title={ + hasDiscordWebhook + ? `${row.label} – Discord` + : 'Bitte zuerst unten eine Discord Webhook URL eintragen' + } + /> +
+
+ ); + })} +
+ +

+ Gebündelt: höchstens alle 5 Minuten eine Nachricht (außer fällige Aufgaben) +

+ {!hasDiscordWebhook && ( +

+ Discord-Schalter sind deaktiviert, solange unten kein Discord-Webhook eingetragen ist. +

+ )} +
+ + {/* Daily Summary */} +
+
+
+

Tageszusammenfassung

+

+ Überfällige, heutige und morgige Aufgaben; nichts fällig = keine Nachricht +

+
+ + setNotificationSettings((prev) => ({ + ...prev, + dailySummary: { ...prev.dailySummary, enabled: v }, + })) + } + title="Tageszusammenfassung" + /> +
+ +
+ + +
+
+ E-Mail + + setNotificationSettings((prev) => ({ + ...prev, + dailySummary: { ...prev.dailySummary, email: v }, + })) + } + title="Tageszusammenfassung – E-Mail" + /> +
+
+ Discord + + setNotificationSettings((prev) => ({ + ...prev, + dailySummary: { ...prev.dailySummary, discord: v }, + })) + } + title={ + hasDiscordWebhook + ? 'Tageszusammenfassung – Discord' + : 'Bitte zuerst unten eine Discord Webhook URL eintragen' + } + /> +
+
+
+
+ + ); + })()} + {/* Lead Time Selector */}
@@ -1044,19 +1282,9 @@ export const UserSettingsView: React.FC = ({
{/* Channel 1: E-Mail */} -
+
-
{/* Channel 2: Discord Webhook */} -
+
-
+ {(task.convertedType === 'document_block' || task.docId) ? (