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

@@ -15,11 +15,13 @@ import {
Lock,
UserPlus,
Repeat,
Eye,
EyeOff,
} from 'lucide-react';
import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
import { UserAvatar } from '../../components/UserAvatar';
import { UserAutocomplete } from '../UserAutocomplete';
import { triggerHaptic } from '../../lib/api';
import { api, triggerHaptic } from '../../lib/api';
import { MarkdownViewer } from '../../lib/markdown';
import { addDays, joinDue, splitDue } from '../../lib/due';
@@ -90,13 +92,32 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
const [newTagDraft, setNewTagDraft] = useState('');
const [isWatching, setIsWatching] = useState<boolean>(Boolean(task.isWatching));
const [watchBusy, setWatchBusy] = useState(false);
useEffect(() => {
setTitleDraft(task.content);
setNotesDraft(task.notes || '');
setIsEditingTitle(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
useEffect(() => {
@@ -226,6 +247,27 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
</div>
<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) ? (
<button
type="button"
@@ -1,6 +1,7 @@
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 { api } from '../../../lib/api';
import { Thought } from '../../../lib/types';
const task: Thought = {
@@ -67,4 +68,20 @@ describe('TaskDetailModal', () => {
expect(screen.getByText('Aufgabe ansehen')).toBeTruthy();
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();
});
});