feat(notifications): add channel routing matrix, daily summary, and task watch button
This commit is contained in:
1 parent
6795edbf4b
commit
d2afcf49df
7 files changed
+461
-32
No files matched your search
@@ -6,6 +6,10 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### 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.
|
- `/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.
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Copy,
|
Copy,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AuthStatus, User, UserTheme } from '../../lib/types';
|
import { AuthStatus, NotificationEvents, NotificationSettings, User, UserTheme } from '../../lib/types';
|
||||||
import { ApiKeysPanel } from './ApiKeysPanel';
|
import { ApiKeysPanel } from './ApiKeysPanel';
|
||||||
import { ServerBackupsCard } from './ServerBackupsCard';
|
import { ServerBackupsCard } from './ServerBackupsCard';
|
||||||
import { saveBlob } from '../../lib/download';
|
import { saveBlob } from '../../lib/download';
|
||||||
@@ -47,6 +47,48 @@ interface UserSettingsViewProps {
|
|||||||
|
|
||||||
type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks';
|
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> = ({
|
export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||||
currentUser,
|
currentUser,
|
||||||
activeTheme,
|
activeTheme,
|
||||||
@@ -211,6 +253,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
const [alarmChannels, setAlarmChannels] = useState<string[]>(
|
const [alarmChannels, setAlarmChannels] = useState<string[]>(
|
||||||
Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []
|
Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []
|
||||||
);
|
);
|
||||||
|
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings>(() =>
|
||||||
|
buildNotificationSettings(currentUser)
|
||||||
|
);
|
||||||
const [alarmEmail, setAlarmEmail] = useState(currentUser.alarmEmail || currentUser.email || '');
|
const [alarmEmail, setAlarmEmail] = useState(currentUser.alarmEmail || currentUser.email || '');
|
||||||
const [discordWebhookUrl, setDiscordWebhookUrl] = useState(currentUser.discordWebhookUrl || '');
|
const [discordWebhookUrl, setDiscordWebhookUrl] = useState(currentUser.discordWebhookUrl || '');
|
||||||
const [customWebhookUrl, setCustomWebhookUrl] = useState(currentUser.customWebhookUrl || '');
|
const [customWebhookUrl, setCustomWebhookUrl] = useState(currentUser.customWebhookUrl || '');
|
||||||
@@ -226,6 +271,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
setEmail(currentUser.email || '');
|
setEmail(currentUser.email || '');
|
||||||
setAvatarUrl(currentUser.avatarUrl || '');
|
setAvatarUrl(currentUser.avatarUrl || '');
|
||||||
setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []);
|
setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []);
|
||||||
|
setNotificationSettings(buildNotificationSettings(currentUser));
|
||||||
setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
|
setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
|
||||||
setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
|
setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
|
||||||
setCustomWebhookUrl(currentUser.customWebhookUrl || '');
|
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 ---
|
// --- Handlers: Avatar ---
|
||||||
const handleAvatarFileSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleAvatarFileSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -324,14 +383,23 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
setAlarmMsg(null);
|
setAlarmMsg(null);
|
||||||
setSavingAlarms(true);
|
setSavingAlarms(true);
|
||||||
try {
|
try {
|
||||||
|
const nextAlarmChannels = [
|
||||||
|
...(notificationSettings.events.due.email ? ['email'] : []),
|
||||||
|
...(notificationSettings.events.due.discord ? ['discord'] : []),
|
||||||
|
...(alarmChannels.includes('webhook') ? ['webhook'] : []),
|
||||||
|
];
|
||||||
const updated = await api.updateSettings({
|
const updated = await api.updateSettings({
|
||||||
alarmChannels,
|
alarmChannels: nextAlarmChannels,
|
||||||
alarmEmail: alarmEmail.trim(),
|
alarmEmail: alarmEmail.trim(),
|
||||||
discordWebhookUrl: discordWebhookUrl.trim(),
|
discordWebhookUrl: discordWebhookUrl.trim(),
|
||||||
customWebhookUrl: customWebhookUrl.trim(),
|
customWebhookUrl: customWebhookUrl.trim(),
|
||||||
notifyBeforeMinutes: Number(notifyBeforeMinutes),
|
notifyBeforeMinutes: Number(notifyBeforeMinutes),
|
||||||
|
notificationSettings,
|
||||||
|
});
|
||||||
|
onUserUpdated({
|
||||||
|
...updated,
|
||||||
|
notificationSettings: updated.notificationSettings ?? notificationSettings,
|
||||||
});
|
});
|
||||||
onUserUpdated(updated);
|
|
||||||
setAlarmMsg({ type: 'ok', text: 'Alarm-Kanäle und Vorwarnzeit erfolgreich gespeichert.' });
|
setAlarmMsg({ type: 'ok', text: 'Alarm-Kanäle und Vorwarnzeit erfolgreich gespeichert.' });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setAlarmMsg({ type: 'err', text: err.message || 'Fehler beim Speichern der Alarme' });
|
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">
|
<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 */}
|
{/* 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 className="p-3.5 bg-canvas border border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -1044,19 +1282,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Channel 1: E-Mail */}
|
{/* Channel 1: E-Mail */}
|
||||||
<div
|
<div className="p-4 border border-border transition-colors space-y-3 bg-canvas">
|
||||||
className={`p-4 border transition-colors space-y-3 bg-canvas ${
|
|
||||||
alarmChannels.includes('email') ? 'border-accent' : 'border-border'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<label className="flex items-center gap-2.5 cursor-pointer select-none">
|
<div className="flex items-center gap-2.5">
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={alarmChannels.includes('email')}
|
|
||||||
onChange={() => toggleAlarmChannel('email')}
|
|
||||||
className="w-4 h-4 accent-blue-600"
|
|
||||||
/>
|
|
||||||
<Mail className="w-4 h-4 text-accent" />
|
<Mail className="w-4 h-4 text-accent" />
|
||||||
<div>
|
<div>
|
||||||
<span className="font-bold text-main block">Ziel: E-Mail</span>
|
<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.
|
Sendet eine E-Mail über den konfigurierten SMTP-Server der Instanz.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1092,19 +1320,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Channel 2: Discord Webhook */}
|
{/* Channel 2: Discord Webhook */}
|
||||||
<div
|
<div className="p-4 border border-border transition-colors space-y-3 bg-canvas">
|
||||||
className={`p-4 border transition-colors space-y-3 bg-canvas ${
|
|
||||||
alarmChannels.includes('discord') ? 'border-accent' : 'border-border'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<label className="flex items-center gap-2.5 cursor-pointer select-none">
|
<div className="flex items-center gap-2.5">
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={alarmChannels.includes('discord')}
|
|
||||||
onChange={() => toggleAlarmChannel('discord')}
|
|
||||||
className="w-4 h-4 accent-blue-600"
|
|
||||||
/>
|
|
||||||
<MessageSquare className="w-4 h-4 text-accent" />
|
<MessageSquare className="w-4 h-4 text-accent" />
|
||||||
<div>
|
<div>
|
||||||
<span className="font-bold text-main block">Ziel: Discord Webhook</span>
|
<span className="font-bold text-main block">Ziel: Discord Webhook</span>
|
||||||
@@ -1113,7 +1331,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
Discord-Kanal.
|
Discord-Kanal.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { UserSettingsView } from '../UserSettingsView';
|
||||||
|
import { api } from '../../../lib/api';
|
||||||
|
import { User } from '../../../lib/types';
|
||||||
|
|
||||||
|
vi.mock('../../../lib/api', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('../../../lib/api')>('../../../lib/api');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
api: {
|
||||||
|
...actual.api,
|
||||||
|
getMe: vi.fn(async () => ({ isLastAdmin: false })),
|
||||||
|
getAuthStatus: vi.fn(async () => ({ demoMode: false })),
|
||||||
|
updateSettings: vi.fn(async (patch) => ({ ...baseUser, ...patch })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseUser: User = {
|
||||||
|
id: 'u1',
|
||||||
|
email: 'anna@example.com',
|
||||||
|
displayName: 'Anna',
|
||||||
|
role: 'member',
|
||||||
|
isActive: true,
|
||||||
|
alarmChannels: ['email'],
|
||||||
|
createdAt: '2026-10-30T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('UserSettingsView notification matrix', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives events.due from alarmChannels, disables Discord without webhook, and saves the exact notificationSettings object', async () => {
|
||||||
|
const onUserUpdated = vi.fn();
|
||||||
|
render(
|
||||||
|
<UserSettingsView
|
||||||
|
currentUser={baseUser}
|
||||||
|
activeTheme="dark"
|
||||||
|
themes={[]}
|
||||||
|
customThemes={[]}
|
||||||
|
onThemeChange={() => {}}
|
||||||
|
onCustomThemesChange={() => {}}
|
||||||
|
onUserUpdated={onUserUpdated}
|
||||||
|
onBack={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open "Alarme" section
|
||||||
|
fireEvent.click(screen.getByText('Alarme'));
|
||||||
|
|
||||||
|
// Without Discord webhook, Discord switches are disabled and hint is visible
|
||||||
|
const discordSwitches = screen.getAllByTitle('Bitte zuerst unten eine Discord Webhook URL eintragen');
|
||||||
|
expect(discordSwitches.length).toBe(5);
|
||||||
|
expect((discordSwitches[0] as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
expect(screen.getByText(/Discord-Schalter sind deaktiviert/)).toBeTruthy();
|
||||||
|
|
||||||
|
// Enter a Discord Webhook URL to enable the Discord column
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/https:\/\/discord\.com\/api\/webhooks/), {
|
||||||
|
target: { value: 'https://discord.com/api/webhooks/123/abc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Derive check: alarmChannels=['email'] -> due.email=true, due.discord=false
|
||||||
|
expect(screen.getByTitle('Fällige Aufgaben (sofort) – E-Mail').getAttribute('aria-checked')).toBe('true');
|
||||||
|
expect(screen.getByTitle('Fällige Aufgaben (sofort) – Discord').getAttribute('aria-checked')).toBe('false');
|
||||||
|
|
||||||
|
// Toggle settings in the matrix, daily summary, and global switches
|
||||||
|
fireEvent.click(screen.getByTitle('Fällige Aufgaben (sofort) – Discord'));
|
||||||
|
fireEvent.click(screen.getByTitle('Geteilte Aufgabe erledigt – E-Mail'));
|
||||||
|
fireEvent.click(screen.getByTitle('Beobachtete Aufgaben – Discord'));
|
||||||
|
fireEvent.click(screen.getByTitle('Alle Benachrichtigungen pausieren'));
|
||||||
|
fireEvent.click(screen.getByTitle('Auch eigene Änderungen an beobachteten Aufgaben melden'));
|
||||||
|
fireEvent.click(screen.getByTitle('Tageszusammenfassung'));
|
||||||
|
fireEvent.change(screen.getByLabelText('Uhrzeit der Tageszusammenfassung'), {
|
||||||
|
target: { value: '08:30' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByTitle('Tageszusammenfassung – Discord'));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Alarm-Einstellungen speichern' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.updateSettings).toHaveBeenCalledTimes(1));
|
||||||
|
expect(api.updateSettings).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
discordWebhookUrl: 'https://discord.com/api/webhooks/123/abc',
|
||||||
|
notificationSettings: {
|
||||||
|
paused: true,
|
||||||
|
includeOwnWatched: true,
|
||||||
|
events: {
|
||||||
|
due: { email: true, discord: true },
|
||||||
|
shared: { email: true, discord: false },
|
||||||
|
taskDone: { email: true, discord: false },
|
||||||
|
watched: { email: true, discord: false },
|
||||||
|
},
|
||||||
|
dailySummary: {
|
||||||
|
enabled: true,
|
||||||
|
time: '08:30',
|
||||||
|
email: true,
|
||||||
|
discord: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,11 +15,13 @@ import {
|
|||||||
Lock,
|
Lock,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Repeat,
|
Repeat,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
|
import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
|
||||||
import { UserAvatar } from '../../components/UserAvatar';
|
import { UserAvatar } from '../../components/UserAvatar';
|
||||||
import { UserAutocomplete } from '../UserAutocomplete';
|
import { UserAutocomplete } from '../UserAutocomplete';
|
||||||
import { triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
import { MarkdownViewer } from '../../lib/markdown';
|
import { MarkdownViewer } from '../../lib/markdown';
|
||||||
import { addDays, joinDue, splitDue } from '../../lib/due';
|
import { addDays, joinDue, splitDue } from '../../lib/due';
|
||||||
|
|
||||||
@@ -90,13 +92,32 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
|
|
||||||
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
|
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
|
||||||
const [newTagDraft, setNewTagDraft] = useState('');
|
const [newTagDraft, setNewTagDraft] = useState('');
|
||||||
|
const [isWatching, setIsWatching] = useState<boolean>(Boolean(task.isWatching));
|
||||||
|
const [watchBusy, setWatchBusy] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTitleDraft(task.content);
|
setTitleDraft(task.content);
|
||||||
setNotesDraft(task.notes || '');
|
setNotesDraft(task.notes || '');
|
||||||
setIsEditingTitle(false);
|
setIsEditingTitle(false);
|
||||||
setIsEditingNotes(false);
|
setIsEditingNotes(false);
|
||||||
}, [task.id]);
|
setIsWatching(Boolean(task.isWatching));
|
||||||
|
}, [task.id, task.isWatching]);
|
||||||
|
|
||||||
|
const handleToggleWatch = async () => {
|
||||||
|
if (watchBusy) return;
|
||||||
|
setWatchBusy(true);
|
||||||
|
try {
|
||||||
|
const res = isWatching ? await api.unwatchTask(task.id) : await api.watchTask(task.id);
|
||||||
|
const next = Boolean(res.watching);
|
||||||
|
setIsWatching(next);
|
||||||
|
task.isWatching = next;
|
||||||
|
triggerHaptic(10);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
} finally {
|
||||||
|
setWatchBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Handle escape key to close
|
// Handle escape key to close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -226,6 +247,27 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleToggleWatch}
|
||||||
|
disabled={watchBusy}
|
||||||
|
title={isWatching ? 'Nicht mehr beobachten' : 'Beobachten'}
|
||||||
|
className={`btn-action min-h-[44px] sm:min-h-0 h-11 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5 ${
|
||||||
|
isWatching ? 'border-accent text-accent' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isWatching ? (
|
||||||
|
<>
|
||||||
|
<EyeOff className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-accent" />
|
||||||
|
<span>Nicht mehr beobachten</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Eye className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
|
||||||
|
<span>Beobachten</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
{(task.convertedType === 'document_block' || task.docId) ? (
|
{(task.convertedType === 'document_block' || task.docId) ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import { TaskDetailModal } from '../TaskDetailModal';
|
import { TaskDetailModal } from '../TaskDetailModal';
|
||||||
|
import { api } from '../../../lib/api';
|
||||||
import { Thought } from '../../../lib/types';
|
import { Thought } from '../../../lib/types';
|
||||||
|
|
||||||
const task: Thought = {
|
const task: Thought = {
|
||||||
@@ -67,4 +68,20 @@ describe('TaskDetailModal', () => {
|
|||||||
expect(screen.getByText('Aufgabe ansehen')).toBeTruthy();
|
expect(screen.getByText('Aufgabe ansehen')).toBeTruthy();
|
||||||
expect((screen.getByLabelText('Fälligkeitsdatum') as HTMLInputElement).closest('fieldset')?.disabled).toBe(true);
|
expect((screen.getByLabelText('Fälligkeitsdatum') as HTMLInputElement).closest('fieldset')?.disabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('toggles watching a task via POST and DELETE /tasks/{id}/watch', async () => {
|
||||||
|
const watchSpy = vi.spyOn(api, 'watchTask').mockResolvedValue({ watching: true });
|
||||||
|
const unwatchSpy = vi.spyOn(api, 'unwatchTask').mockResolvedValue({ watching: false });
|
||||||
|
|
||||||
|
render(<TaskDetailModal {...props({ task: { ...task, isWatching: false } })} />);
|
||||||
|
const btn = screen.getByRole('button', { name: 'Beobachten' });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
|
||||||
|
await waitFor(() => expect(watchSpy).toHaveBeenCalledWith('th_1'));
|
||||||
|
expect(screen.getByRole('button', { name: 'Nicht mehr beobachten' })).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Nicht mehr beobachten' }));
|
||||||
|
await waitFor(() => expect(unwatchSpy).toHaveBeenCalledWith('th_1'));
|
||||||
|
expect(screen.getByRole('button', { name: 'Beobachten' })).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
TagSummary,
|
TagSummary,
|
||||||
User,
|
User,
|
||||||
UserInvite,
|
UserInvite,
|
||||||
|
NotificationSettings,
|
||||||
VoiceAdminStatus,
|
VoiceAdminStatus,
|
||||||
MergeResult,
|
MergeResult,
|
||||||
WSMessage,
|
WSMessage,
|
||||||
@@ -292,6 +293,7 @@ export const api = {
|
|||||||
discordWebhookUrl?: string;
|
discordWebhookUrl?: string;
|
||||||
customWebhookUrl?: string;
|
customWebhookUrl?: string;
|
||||||
notifyBeforeMinutes?: number;
|
notifyBeforeMinutes?: number;
|
||||||
|
notificationSettings?: NotificationSettings;
|
||||||
}): Promise<User> {
|
}): Promise<User> {
|
||||||
return request<User>('/api/v1/auth/settings', {
|
return request<User>('/api/v1/auth/settings', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -410,6 +412,18 @@ export const api = {
|
|||||||
return ensureArray(res);
|
return ensureArray(res);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async watchTask(taskId: string): Promise<{ watching: boolean }> {
|
||||||
|
return request<{ watching: boolean }>(`/api/v1/tasks/${encodeURIComponent(taskId)}/watch`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async unwatchTask(taskId: string): Promise<{ watching: boolean }> {
|
||||||
|
return request<{ watching: boolean }>(`/api/v1/tasks/${encodeURIComponent(taskId)}/watch`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async reorderTasks(items: { id: string; sortOrder: number; kanbanStatus?: string }[], taskListId?: string): Promise<{ success: boolean }> {
|
async reorderTasks(items: { id: string; sortOrder: number; kanbanStatus?: string }[], taskListId?: string): Promise<{ success: boolean }> {
|
||||||
const url = taskListId ? `/api/v1/task-lists/${taskListId}/tasks/reorder` : '/api/v1/tasks/reorder';
|
const url = taskListId ? `/api/v1/task-lists/${taskListId}/tasks/reorder` : '/api/v1/tasks/reorder';
|
||||||
return request<{ success: boolean }>(url, {
|
return request<{ success: boolean }>(url, {
|
||||||
|
|||||||
@@ -46,6 +46,32 @@ export interface VoiceAdminStatus {
|
|||||||
// Who can see whom: nobody, members of the same organization, or everybody (Doku/sharing-and-permissions.md)
|
// Who can see whom: nobody, members of the same organization, or everybody (Doku/sharing-and-permissions.md)
|
||||||
export type SharingMode = 'private' | 'organizations' | 'open';
|
export type SharingMode = 'private' | 'organizations' | 'open';
|
||||||
|
|
||||||
|
export interface NotificationChannelRouting {
|
||||||
|
email: boolean;
|
||||||
|
discord: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationEvents {
|
||||||
|
due: NotificationChannelRouting;
|
||||||
|
shared: NotificationChannelRouting;
|
||||||
|
taskDone: NotificationChannelRouting;
|
||||||
|
watched: NotificationChannelRouting;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailySummarySettings {
|
||||||
|
enabled: boolean;
|
||||||
|
time: string;
|
||||||
|
email: boolean;
|
||||||
|
discord: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationSettings {
|
||||||
|
paused: boolean;
|
||||||
|
includeOwnWatched: boolean;
|
||||||
|
events: NotificationEvents;
|
||||||
|
dailySummary: DailySummarySettings;
|
||||||
|
}
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -59,6 +85,7 @@ export interface User {
|
|||||||
discordWebhookUrl?: string;
|
discordWebhookUrl?: string;
|
||||||
customWebhookUrl?: string;
|
customWebhookUrl?: string;
|
||||||
notifyBeforeMinutes?: number;
|
notifyBeforeMinutes?: number;
|
||||||
|
notificationSettings?: NotificationSettings;
|
||||||
organizationId?: string;
|
organizationId?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -113,6 +140,7 @@ export interface Thought {
|
|||||||
subtasks?: SubTask[];
|
subtasks?: SubTask[];
|
||||||
assignees?: string[]; // user IDs, only people with access to the task list
|
assignees?: string[]; // user IDs, only people with access to the task list
|
||||||
recurrence?: Recurrence;
|
recurrence?: Recurrence;
|
||||||
|
isWatching?: boolean;
|
||||||
alarmSent?: boolean;
|
alarmSent?: boolean;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
Reference in new issue
Block a user