feat(notifications): add channel routing matrix, daily summary, and task watch button

This commit is contained in:
sandrodach committed 2026-10-30 09:32:52 +01:00
1 parent 6795edbf4b
commit d2afcf49df
7 files changed
+461 -32

No files matched your search

@@ -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<UserSettingsViewProps> = ({
currentUser,
activeTheme,
@@ -211,6 +253,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
const [alarmChannels, setAlarmChannels] = useState<string[]>(
Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []
);
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings>(() =>
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<UserSettingsViewProps> = ({
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<UserSettingsViewProps> = ({
);
};
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<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -324,14 +383,23 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
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<UserSettingsViewProps> = ({
)}
<form onSubmit={handleSaveAlarms} className="space-y-4">
{/* Global Pause & Own Watched Toggles */}
<div className="p-3.5 bg-canvas border border-border space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-semibold text-main">Alle Benachrichtigungen pausieren</p>
<p className="text-[11px] text-muted">
Pausiert vorübergehend sämtliche E-Mail- und Discord-Benachrichtigungen.
</p>
</div>
<ToggleSwitch
checked={notificationSettings.paused}
onChange={(v) => setNotificationSettings((prev) => ({ ...prev, paused: v }))}
title="Alle Benachrichtigungen pausieren"
/>
</div>
<div className="flex items-center justify-between gap-4 pt-3 border-t border-border">
<div>
<p className="font-semibold text-main">
Auch eigene Änderungen an beobachteten Aufgaben melden
</p>
<p className="text-[11px] text-muted">
Standardmäßig wirst du nur über Änderungen anderer Personen benachrichtigt.
</p>
</div>
<ToggleSwitch
checked={notificationSettings.includeOwnWatched}
onChange={(v) => setNotificationSettings((prev) => ({ ...prev, includeOwnWatched: v }))}
title="Auch eigene Änderungen an beobachteten Aufgaben melden"
/>
</div>
</div>
{/* Routing Matrix: Events x (E-Mail / Discord) */}
{(() => {
const hasDiscordWebhook = Boolean(discordWebhookUrl.trim());
return (
<>
<div className="p-4 bg-canvas border border-border space-y-3">
<div className="grid grid-cols-[1fr_auto_auto] items-center gap-4 pb-2 border-b border-border">
<span className="font-bold text-main">Ereignis</span>
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
<Mail className="w-3.5 h-3.5 text-accent" />
<span>E-Mail</span>
</span>
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
<MessageSquare className="w-3.5 h-3.5 text-accent" />
<span>Discord</span>
</span>
</div>
<div className="divide-y divide-border">
{NOTIFICATION_EVENT_ROWS.map((row) => {
const routing = notificationSettings.events[row.key];
return (
<div
key={row.key}
className="grid grid-cols-[1fr_auto_auto] items-center gap-4 py-2.5"
>
<span className="font-medium text-main">{row.label}</span>
<div className="w-16 flex justify-center">
<ToggleSwitch
checked={routing.email}
onChange={(v) => setEventChannel(row.key, 'email', v)}
title={`${row.label} – E-Mail`}
/>
</div>
<div className="w-16 flex justify-center">
<ToggleSwitch
checked={routing.discord}
disabled={!hasDiscordWebhook}
onChange={(v) => setEventChannel(row.key, 'discord', v)}
title={
hasDiscordWebhook
? `${row.label} – Discord`
: 'Bitte zuerst unten eine Discord Webhook URL eintragen'
}
/>
</div>
</div>
);
})}
</div>
<p className="text-[11px] text-faint pt-1">
Gebündelt: höchstens alle 5 Minuten eine Nachricht (außer fällige Aufgaben)
</p>
{!hasDiscordWebhook && (
<p className="text-[11px] text-status-warn">
Discord-Schalter sind deaktiviert, solange unten kein Discord-Webhook eingetragen ist.
</p>
)}
</div>
{/* Daily Summary */}
<div className="p-4 bg-canvas border border-border space-y-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="font-bold text-main">Tageszusammenfassung</p>
<p className="text-[11px] text-muted">
Überfällige, heutige und morgige Aufgaben; nichts fällig = keine Nachricht
</p>
</div>
<ToggleSwitch
checked={notificationSettings.dailySummary.enabled}
onChange={(v) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, enabled: v },
}))
}
title="Tageszusammenfassung"
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-4 pt-2 border-t border-border">
<label className="flex items-center gap-2 text-xs text-main font-medium">
<span>Uhrzeit:</span>
<input
type="time"
aria-label="Uhrzeit der Tageszusammenfassung"
value={notificationSettings.dailySummary.time}
onChange={(e) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, time: e.target.value },
}))
}
className="h-11 sm:h-8 px-2.5 input-field font-mono"
/>
</label>
<div className="flex items-center gap-5">
<div className="flex items-center gap-2">
<span className="text-xs text-muted">E-Mail</span>
<ToggleSwitch
checked={notificationSettings.dailySummary.email}
onChange={(v) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, email: v },
}))
}
title="Tageszusammenfassung – E-Mail"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Discord</span>
<ToggleSwitch
checked={notificationSettings.dailySummary.discord}
disabled={!hasDiscordWebhook}
onChange={(v) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, discord: v },
}))
}
title={
hasDiscordWebhook
? 'Tageszusammenfassung – Discord'
: 'Bitte zuerst unten eine Discord Webhook URL eintragen'
}
/>
</div>
</div>
</div>
</div>
</>
);
})()}
{/* Lead Time Selector */}
<div className="p-3.5 bg-canvas border border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
@@ -1044,19 +1282,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</div>
{/* Channel 1: E-Mail */}
<div
className={`p-4 border transition-colors space-y-3 bg-canvas ${
alarmChannels.includes('email') ? 'border-accent' : 'border-border'
}`}
>
<div className="p-4 border border-border transition-colors space-y-3 bg-canvas">
<div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2.5 cursor-pointer select-none">
<input
type="checkbox"
checked={alarmChannels.includes('email')}
onChange={() => toggleAlarmChannel('email')}
className="w-4 h-4 accent-blue-600"
/>
<div className="flex items-center gap-2.5">
<Mail className="w-4 h-4 text-accent" />
<div>
<span className="font-bold text-main block">Ziel: E-Mail</span>
@@ -1064,7 +1292,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
Sendet eine E-Mail über den konfigurierten SMTP-Server der Instanz.
</span>
</div>
</label>
</div>
<button
type="button"
@@ -1092,19 +1320,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</div>
{/* Channel 2: Discord Webhook */}
<div
className={`p-4 border transition-colors space-y-3 bg-canvas ${
alarmChannels.includes('discord') ? 'border-accent' : 'border-border'
}`}
>
<div className="p-4 border border-border transition-colors space-y-3 bg-canvas">
<div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2.5 cursor-pointer select-none">
<input
type="checkbox"
checked={alarmChannels.includes('discord')}
onChange={() => toggleAlarmChannel('discord')}
className="w-4 h-4 accent-blue-600"
/>
<div className="flex items-center gap-2.5">
<MessageSquare className="w-4 h-4 text-accent" />
<div>
<span className="font-bold text-main block">Ziel: Discord Webhook</span>
@@ -1113,7 +1331,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
Discord-Kanal.
</span>
</div>
</label>
</div>
<button
type="button"