feat(260929-if2): faellige Erinnerungen erledigen, spaeter erinnern, bearbeiten und loeschen

- API: Aendern (409 wenn faellig), Spaeter erinnern (409 wenn nicht faellig, setzt E-Mail-Spur zurueck), Loeschen; fremde Kennungen 404
- Kachel: faellige Zeilen hervorgehoben mit Erledigt und drei Spaeter-Optionen, kuenftige mit Bearbeiten und Loeschen
- Zeit-Hilfen (morgen zur gleichen Uhrzeit), Zugriffsklassifikation nachgemessen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 13:59:43 +02:00
parent 325c5ddbf2
commit 709b41a007
16 changed files with 946 additions and 57 deletions
@@ -4,13 +4,22 @@ import { useTranslations } from 'next-intl';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { requestBrowserPermissionOnce } from '@/lib/reminder-notify';
import { defaultNewReminderInputs, localInputsToIso } from '@/lib/reminder-time';
import { ReminderRequestError, createReminder } from '@/lib/reminders-api';
import { defaultNewReminderInputs, isoToLocalInputs, localInputsToIso } from '@/lib/reminder-time';
import {
type Reminder,
ReminderRequestError,
createReminder,
updateReminder,
} from '@/lib/reminders-api';
interface ReminderFormModalProps {
/** `null` = neue Erinnerung anlegen, sonst diese (noch nicht faellige) bearbeiten. */
reminder: Reminder | null;
onClose: () => void;
/** Nach erfolgreichem Speichern (die Kachel laedt neu und meldet die Aenderung). */
onSaved: () => void;
/** Der Server meldet, dass die Erinnerung inzwischen faellig ist (409) — die Kachel laedt neu. */
onStale?: () => void;
}
const TITLE_MAX = 200;
@@ -25,18 +34,23 @@ const DESCRIPTION_MAX = 2000;
* macht sich fuer `position: fixed` zum Bezugsrahmen (Vorbild
* `picture-frame-lightbox.tsx`). Dialog-Aufbau wie `custom-module-form-modal`.
*
* ERLAUBNIS (D-04): `requestBrowserPermissionOnce()` laeuft als ERSTES im
* Absende-Handler, synchron und noch vor jedem `await` — so bleibt der Aufruf
* BEARBEITEN (`reminder` gesetzt): nur fuer eine noch nicht faellige Erinnerung;
* eine inzwischen faellige lehnt der Server mit 409 ab (`alreadyDue`).
*
* ERLAUBNIS (D-04): `requestBrowserPermissionOnce()` laeuft beim ANLEGEN als
* ERSTES im Absende-Handler, synchron und noch vor jedem `await` — so bleibt der Aufruf
* eine Nutzerhandlung, die der Browser fuer die Frage akzeptiert. Nie beim
* Laden der Seite.
*/
export function ReminderFormModal({ onClose, onSaved }: ReminderFormModalProps) {
export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: ReminderFormModalProps) {
const t = useTranslations('widgets');
const initial = useRef(defaultNewReminderInputs(new Date())).current;
const initial = useRef(
reminder ? isoToLocalInputs(reminder.dueAt) : defaultNewReminderInputs(new Date()),
).current;
const [date, setDate] = useState(initial.date);
const [time, setTime] = useState(initial.time);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [title, setTitle] = useState(reminder?.title ?? '');
const [description, setDescription] = useState(reminder?.description ?? '');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const titleRef = useRef<HTMLInputElement>(null);
@@ -53,7 +67,7 @@ export function ReminderFormModal({ onClose, onSaved }: ReminderFormModalProps)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Als Erstes und synchron: Nutzerhandlung fuer die Browser-Erlaubnis (D-04).
requestBrowserPermissionOnce();
if (!reminder) requestBrowserPermissionOnce();
setError(null);
const trimmedTitle = title.trim();
@@ -70,15 +84,22 @@ export function ReminderFormModal({ onClose, onSaved }: ReminderFormModalProps)
setSaving(true);
try {
await createReminder({ title: trimmedTitle, description: description.trim(), dueAt });
const input = { title: trimmedTitle, description: description.trim(), dueAt };
if (reminder) {
await updateReminder(reminder.id, input);
} else {
await createReminder(input);
}
onSaved();
onClose();
} catch (err) {
setError(
err instanceof ReminderRequestError && err.status === 409
? t('reminder.limitReached')
: t('reminder.saveError'),
);
if (err instanceof ReminderRequestError && err.status === 409) {
// Anlegen: Grenze erreicht; Bearbeiten: inzwischen faellig.
setError(reminder ? t('reminder.alreadyDue') : t('reminder.limitReached'));
if (reminder) onStale?.();
} else {
setError(t('reminder.saveError'));
}
} finally {
setSaving(false);
}
@@ -95,7 +116,7 @@ export function ReminderFormModal({ onClose, onSaved }: ReminderFormModalProps)
className="w-full max-w-md rounded-lg bg-card p-6 shadow-lg dark:border dark:border-border"
>
<h2 id="reminder-form-title" className="mb-4 text-lg font-semibold text-foreground">
{t('reminder.formTitle')}
{reminder ? t('reminder.editTitle') : t('reminder.formTitle')}
</h2>
<form onSubmit={handleSubmit} noValidate className="space-y-4">
<div className="flex gap-3">
@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('next-intl', () => ({
@@ -13,14 +13,27 @@ vi.mock('@/lib/reminders-api', async () => {
ReminderRequestError: actual.ReminderRequestError,
listReminders: vi.fn(),
createReminder: vi.fn(),
updateReminder: vi.fn(),
snoozeReminder: vi.fn(),
deleteReminder: vi.fn(),
};
});
import { createReminder, listReminders } from '@/lib/reminders-api';
import {
ReminderRequestError,
createReminder,
deleteReminder,
listReminders,
snoozeReminder,
updateReminder,
} from '@/lib/reminders-api';
import { ReminderWidget } from './reminder-widget';
const mockList = listReminders as ReturnType<typeof vi.fn>;
const mockCreate = createReminder as ReturnType<typeof vi.fn>;
const mockUpdate = updateReminder as ReturnType<typeof vi.fn>;
const mockSnooze = snoozeReminder as ReturnType<typeof vi.fn>;
const mockDelete = deleteReminder as ReturnType<typeof vi.fn>;
function reminder(id: string, title: string, dueAt: string, description = '') {
return {
@@ -42,6 +55,9 @@ beforeEach(() => {
window.localStorage.clear();
mockList.mockReset();
mockCreate.mockReset();
mockUpdate.mockReset();
mockSnooze.mockReset();
mockDelete.mockReset();
requestPermission = vi.fn(async () => 'granted');
const ctor = vi.fn();
Object.assign(ctor, { permission: 'default', requestPermission });
@@ -145,3 +161,149 @@ describe('ReminderWidget', () => {
window.removeEventListener('tessera:reminders-changed', handler);
});
});
const iso = (offsetMs: number) => new Date(Date.now() + offsetMs).toISOString();
describe('ReminderWidget — faellig und Aktionen (D-03)', () => {
it('eine faellige Zeile ist hervorgehoben mit Faellig, Erledigt und Spaeter erinnern, ohne Bearbeiten/Loeschen', async () => {
mockList.mockResolvedValue([reminder('a', 'Faellig', iso(-60_000))]);
render(<ReminderWidget {...props} />);
const row = await screen.findByTestId('reminder-row');
expect(row.getAttribute('data-due')).toBe('true');
expect(row.className).toContain('border-status-warn');
expect(within(row).getByText('reminder.due')).toBeTruthy();
expect(within(row).getByText('reminder.done')).toBeTruthy();
expect(within(row).getByText('reminder.snooze')).toBeTruthy();
expect(within(row).queryByLabelText('reminder.edit')).toBeNull();
expect(within(row).queryByLabelText('reminder.delete')).toBeNull();
});
it('eine kuenftige Zeile bietet Bearbeiten und Loeschen, aber nicht Erledigt/Spaeter', async () => {
mockList.mockResolvedValue([reminder('a', 'Kommt', iso(3600_000))]);
render(<ReminderWidget {...props} />);
const row = await screen.findByTestId('reminder-row');
expect(row.getAttribute('data-due')).toBe('false');
expect(within(row).getByLabelText('reminder.edit')).toBeTruthy();
expect(within(row).getByLabelText('reminder.delete')).toBeTruthy();
expect(within(row).queryByText('reminder.done')).toBeNull();
expect(within(row).queryByText('reminder.snooze')).toBeNull();
});
it('Erledigt ruft deleteReminder und die Zeile verschwindet nach dem Neuladen', async () => {
mockList.mockResolvedValueOnce([reminder('a', 'Faellig', iso(-60_000))]).mockResolvedValue([]);
mockDelete.mockResolvedValue({ deleted: true });
render(<ReminderWidget {...props} />);
fireEvent.click(await screen.findByText('reminder.done'));
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('a'));
await waitFor(() => expect(screen.queryByTestId('reminder-row')).toBeNull());
});
it('jede Spaeter-Option ruft snoozeReminder mit dem Zeitpunkt aus snoozeTarget', async () => {
const original = new Date(Date.now() - 5 * 60_000);
mockList.mockResolvedValue([reminder('a', 'Faellig', original.toISOString())]);
mockSnooze.mockResolvedValue({});
render(<ReminderWidget {...props} />);
for (const [label, expected] of [
['reminder.snooze10m', () => Date.now() + 10 * 60_000],
['reminder.snooze1h', () => Date.now() + 60 * 60_000],
] as const) {
mockSnooze.mockClear();
fireEvent.click(await screen.findByText('reminder.snooze'));
fireEvent.click(await screen.findByText(label));
await waitFor(() => expect(mockSnooze).toHaveBeenCalledTimes(1));
const [id, dueAt] = mockSnooze.mock.calls[0];
expect(id).toBe('a');
expect(Math.abs(new Date(dueAt).getTime() - expected())).toBeLessThan(5_000);
}
mockSnooze.mockClear();
fireEvent.click(await screen.findByText('reminder.snooze'));
fireEvent.click(await screen.findByText('reminder.snoozeTomorrow'));
await waitFor(() => expect(mockSnooze).toHaveBeenCalledTimes(1));
const tomorrow = new Date(original);
tomorrow.setDate(tomorrow.getDate() + 1);
expect(mockSnooze.mock.calls[0][1]).toBe(tomorrow.toISOString());
});
it('Loeschen fragt zweistufig nach und ruft dann deleteReminder', async () => {
mockList.mockResolvedValue([reminder('a', 'Kommt', iso(3600_000))]);
mockDelete.mockResolvedValue({ deleted: true });
render(<ReminderWidget {...props} />);
fireEvent.click(await screen.findByLabelText('reminder.delete'));
expect(mockDelete).not.toHaveBeenCalled();
fireEvent.click(screen.getByText('reminder.yes'));
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('a'));
});
it('Bearbeiten oeffnet das Formular mit den Werten und speichert per updateReminder', async () => {
const due = new Date(Date.now() + 3600_000);
mockList.mockResolvedValue([reminder('a', 'Alt', due.toISOString(), 'Text')]);
mockUpdate.mockResolvedValue({});
render(<ReminderWidget {...props} />);
fireEvent.click(await screen.findByLabelText('reminder.edit'));
const titleInput = screen.getByLabelText('reminder.titleLabel') as HTMLInputElement;
expect(titleInput.value).toBe('Alt');
fireEvent.change(titleInput, { target: { value: 'Neu' } });
fireEvent.click(screen.getByText('reminder.save'));
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
const [id, patch] = mockUpdate.mock.calls[0];
expect(id).toBe('a');
expect(patch.title).toBe('Neu');
expect(patch.description).toBe('Text');
// Bearbeiten fragt nie die Benachrichtigungs-Erlaubnis
expect(requestPermission).not.toHaveBeenCalled();
});
it('409 beim Verschieben zeigt den Hinweis und laedt neu', async () => {
mockList.mockResolvedValue([reminder('a', 'Faellig', iso(-60_000))]);
mockSnooze.mockRejectedValue(new ReminderRequestError(409));
render(<ReminderWidget {...props} />);
fireEvent.click(await screen.findByText('reminder.snooze'));
fireEvent.click(await screen.findByText('reminder.snooze10m'));
expect(await screen.findByText('reminder.notDue')).toBeTruthy();
});
it('zeigt bei blockierten Browser-Benachrichtigungen einen Hinweis, sonst nicht', async () => {
mockList.mockResolvedValue([]);
(Notification as unknown as { permission: string }).permission = 'denied';
const { unmount } = render(<ReminderWidget {...props} />);
expect(await screen.findByTestId('reminder-permission-hint')).toBeTruthy();
unmount();
(Notification as unknown as { permission: string }).permission = 'default';
render(<ReminderWidget {...props} />);
await screen.findByText('reminder.empty');
expect(screen.queryByTestId('reminder-permission-hint')).toBeNull();
});
it('zeigt in der Desktop-App keinen Hinweis', async () => {
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ = { invoke: vi.fn() };
(Notification as unknown as { permission: string }).permission = 'denied';
mockList.mockResolvedValue([]);
render(<ReminderWidget {...props} />);
await screen.findByText('reminder.empty');
expect(screen.queryByTestId('reminder-permission-hint')).toBeNull();
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
});
});
describe('ReminderWidget — Faelligkeit ohne Neuladen', () => {
afterEach(() => {
vi.useRealTimers();
});
it('eine Zeile wird durch den lokalen 10-s-Takt faellig', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-09-29T12:00:00.000Z'));
mockList.mockResolvedValue([reminder('a', 'Bald', '2026-09-29T12:00:30.000Z')]);
render(<ReminderWidget {...props} />);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(screen.getByTestId('reminder-row').getAttribute('data-due')).toBe('false');
await act(async () => {
await vi.advanceTimersByTimeAsync(40_000);
});
expect(screen.getByTestId('reminder-row').getAttribute('data-due')).toBe('true');
});
});
@@ -2,31 +2,49 @@
import { useLocale, useTranslations } from 'next-intl';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { browserPermissionState } from '@/lib/reminder-notify';
import { type SnoozePreset, snoozeTarget } from '@/lib/reminder-time';
import {
REMINDERS_CHANGED_EVENT,
type Reminder,
ReminderRequestError,
deleteReminder,
listReminders,
snoozeReminder,
} from '@/lib/reminders-api';
import type { WidgetProps } from '../widget-registry';
import { ReminderFormModal } from './reminder-form-modal';
/** Abstand der Auffrischung beim Server. */
const REFRESH_MS = 60_000;
/** Abstand, in dem die Kachel neu entscheidet, welche Erinnerung faellig ist. */
const NOW_TICK_MS = 10_000;
const SNOOZE_PRESETS: SnoozePreset[] = ['10m', '1h', 'tomorrow'];
/**
* Kachel „Erinnerungen“ (quick-260929-if2): die persoenlichen Erinnerungen des
* Benutzers, die naechste Faelligkeit zuerst, dazu „Neue Erinnerung“. Die
* Benachrichtigung selbst macht der globale `ReminderNotifier` im Portal-
* Rahmen — die Kachel muss dafuer nicht sichtbar sein. Nach jeder Aenderung
* laedt sie neu und meldet `REMINDERS_CHANGED_EVENT`, damit der Melder eine
* neue Faelligkeit sofort kennt.
* Benutzers, die naechste Faelligkeit zuerst. Die Benachrichtigung selbst macht
* der globale `ReminderNotifier` im Portal-Rahmen — die Kachel muss dafuer
* nicht sichtbar sein. Nach jeder Aenderung laedt sie neu und meldet
* `REMINDERS_CHANGED_EVENT`, damit der Melder eine neue Faelligkeit sofort kennt.
*
* Faellige Zeilen (D-03) bleiben hervorgehoben stehen und bieten „Erledigt“
* (loescht die Zeile, E-02) und „Spaeter erinnern“ (+10 Minuten, +1 Stunde,
* morgen zur gleichen Uhrzeit). Noch nicht faellige lassen sich bearbeiten und
* loeschen. Ob eine Zeile faellig ist, entscheidet ein 10-s-Takt lokal, ohne
* Neuladen.
*/
export function ReminderWidget(_props: WidgetProps) {
const t = useTranslations('widgets');
const locale = useLocale();
const [reminders, setReminders] = useState<Reminder[] | null>(null);
const [loadError, setLoadError] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [now, setNow] = useState(() => Date.now());
const [modal, setModal] = useState<{ reminder: Reminder | null } | null>(null);
const [snoozeOpenId, setSnoozeOpenId] = useState<string | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const refetch = useCallback(async () => {
try {
@@ -39,11 +57,13 @@ export function ReminderWidget(_props: WidgetProps) {
useEffect(() => {
void refetch();
const timer = setInterval(() => void refetch(), REFRESH_MS);
const refresh = setInterval(() => void refetch(), REFRESH_MS);
const tick = setInterval(() => setNow(Date.now()), NOW_TICK_MS);
const onChanged = () => void refetch();
window.addEventListener(REMINDERS_CHANGED_EVENT, onChanged);
return () => {
clearInterval(timer);
clearInterval(refresh);
clearInterval(tick);
window.removeEventListener(REMINDERS_CHANGED_EVENT, onChanged);
};
}, [refetch]);
@@ -56,16 +76,61 @@ export function ReminderWidget(_props: WidgetProps) {
[reminders],
);
const formatter = useMemo(
const dateTime = useMemo(
() => new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }),
[locale],
);
const timeOnly = useMemo(() => new Intl.DateTimeFormat(locale, { timeStyle: 'short' }), [locale]);
const handleSaved = () => {
/** Nach jeder erfolgreichen Aenderung: neu laden und den Melder informieren. */
const afterMutation = () => {
void refetch();
window.dispatchEvent(new Event(REMINDERS_CHANGED_EVENT));
};
/** Fehler einer Zeilenaktion: 409 heisst, der Stand war veraltet — Text zeigen und neu laden. */
const handleFailure = (err: unknown, conflictKey: string) => {
setNotice(
err instanceof ReminderRequestError && err.status === 409 ? t(conflictKey) : t('reminder.saveError'),
);
void refetch();
};
const markDone = async (id: string) => {
setNotice(null);
try {
await deleteReminder(id);
afterMutation();
} catch (err) {
handleFailure(err, 'reminder.saveError');
}
};
const snooze = async (r: Reminder, preset: SnoozePreset) => {
setNotice(null);
setSnoozeOpenId(null);
try {
const target = snoozeTarget(preset, new Date(r.dueAt), new Date());
await snoozeReminder(r.id, target.toISOString());
afterMutation();
} catch (err) {
handleFailure(err, 'reminder.notDue');
}
};
const remove = async (id: string) => {
setNotice(null);
setConfirmDeleteId(null);
try {
await deleteReminder(id);
afterMutation();
} catch (err) {
handleFailure(err, 'reminder.saveError');
}
};
const blocked = browserPermissionState() === 'denied';
return (
<div className="flex h-full flex-col gap-2 p-3" data-testid="reminder-widget">
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto">
@@ -74,34 +139,179 @@ export function ReminderWidget(_props: WidgetProps) {
{t('reminder.loadError')}
</p>
)}
{notice && (
<p role="alert" className="text-sm text-destructive">
{notice}
</p>
)}
{!loadError && reminders !== null && sorted.length === 0 && (
<p className="text-sm text-muted-foreground">{t('reminder.empty')}</p>
)}
{sorted.map((r) => (
<div
key={r.id}
data-testid="reminder-row"
className="rounded-md border border-border bg-background/60 px-3 py-2"
>
<p className="truncate text-sm font-medium text-foreground">{r.title}</p>
<p className="text-xs text-muted-foreground">{formatter.format(new Date(r.dueAt))}</p>
{r.description !== '' && (
<p className="mt-1 line-clamp-2 whitespace-pre-line text-xs text-muted-foreground">
{r.description}
</p>
)}
</div>
))}
{sorted.map((r) => {
const due = new Date(r.dueAt).getTime() <= now;
return (
<div
key={r.id}
data-testid="reminder-row"
data-due={due ? 'true' : 'false'}
className={
due
? 'rounded-md border border-status-warn bg-status-warn/10 px-3 py-2'
: 'rounded-md border border-border bg-background/60 px-3 py-2'
}
>
<div className="flex items-start justify-between gap-2">
<p className="min-w-0 truncate text-sm font-medium text-foreground">{r.title}</p>
{due && (
<span className="shrink-0 rounded-full bg-status-warn/20 px-2 py-0.5 text-xs font-semibold text-status-warn-fg">
{t('reminder.due')}
</span>
)}
</div>
<p className="text-xs text-muted-foreground">{dateTime.format(new Date(r.dueAt))}</p>
{r.description !== '' && (
<p className="mt-1 line-clamp-2 whitespace-pre-line text-xs text-muted-foreground">
{r.description}
</p>
)}
{due ? (
<div className="mt-2 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => void markDone(r.id)}
className="btn btn-primary"
>
{t('reminder.done')}
</button>
<button
type="button"
onClick={() => setSnoozeOpenId(snoozeOpenId === r.id ? null : r.id)}
aria-expanded={snoozeOpenId === r.id}
className="btn btn-secondary"
>
{t('reminder.snooze')}
</button>
{snoozeOpenId === r.id && (
<div className="flex w-full flex-wrap gap-2">
{SNOOZE_PRESETS.map((preset) => (
<button
key={preset}
type="button"
onClick={() => void snooze(r, preset)}
className="btn btn-subtle border-border"
>
{preset === '10m' && t('reminder.snooze10m')}
{preset === '1h' && t('reminder.snooze1h')}
{preset === 'tomorrow' &&
t('reminder.snoozeTomorrow', { time: timeOnly.format(new Date(r.dueAt)) })}
</button>
))}
</div>
)}
</div>
) : (
<div className="mt-2 flex flex-wrap items-center gap-2">
{confirmDeleteId === r.id ? (
<>
<span className="text-xs text-foreground">{t('reminder.deleteConfirm')}</span>
<button
type="button"
onClick={() => void remove(r.id)}
className="btn btn-secondary"
>
{t('reminder.yes')}
</button>
<button
type="button"
onClick={() => setConfirmDeleteId(null)}
className="btn btn-secondary"
>
{t('reminder.no')}
</button>
</>
) : (
<>
<button
type="button"
onClick={() => {
setNotice(null);
setModal({ reminder: r });
}}
className="btn btn-subtle btn-icon"
aria-label={t('reminder.edit')}
title={t('reminder.edit')}
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
</button>
<button
type="button"
onClick={() => setConfirmDeleteId(r.id)}
className="btn btn-subtle btn-icon"
aria-label={t('reminder.delete')}
title={t('reminder.delete')}
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 6h18" />
<path d="M8 6V4h8v2" />
<path d="M19 6l-1 14H6L5 6" />
</svg>
</button>
</>
)}
</div>
)}
</div>
);
})}
</div>
{blocked && (
<p className="text-xs text-muted-foreground" data-testid="reminder-permission-hint">
{t('reminder.permissionDenied')}
</p>
)}
<button
type="button"
onClick={() => setModalOpen(true)}
onClick={() => {
setNotice(null);
setModal({ reminder: null });
}}
className="btn btn-secondary shrink-0 self-start"
>
{t('reminder.add')}
</button>
{modalOpen && (
<ReminderFormModal onClose={() => setModalOpen(false)} onSaved={handleSaved} />
{modal && (
<ReminderFormModal
reminder={modal.reminder}
onClose={() => setModal(null)}
onSaved={afterMutation}
onStale={() => void refetch()}
/>
)}
</div>
);