feat(260929-if2): Erinnerung zusaetzlich per E-Mail, Doku und Aenderungsliste

- E-Mail-Planer: Anspruch vor dem Senden (genau eine Mail je Faelligkeit, hoechstens 3 Versuche), Systemlesen nur fuer die Kandidatenabfrage
- MailService.sendReminderEmail (Berliner Zeit, nur Text), GET /reminders/email-status, Haken im Formular mit Erklaerung
- Zugriffsklassifikation und Erlaubnisliste fuer forSystem nachgezogen, Aenderungsliste und Anwenderanleitung

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 14:10:06 +02:00
parent 709b41a007
commit 6879c756f2
21 changed files with 1038 additions and 21 deletions
@@ -7,6 +7,7 @@ import { requestBrowserPermissionOnce } from '@/lib/reminder-notify';
import { defaultNewReminderInputs, isoToLocalInputs, localInputsToIso } from '@/lib/reminder-time';
import {
type Reminder,
type ReminderEmailStatus,
ReminderRequestError,
createReminder,
updateReminder,
@@ -15,6 +16,8 @@ import {
interface ReminderFormModalProps {
/** `null` = neue Erinnerung anlegen, sonst diese (noch nicht faellige) bearbeiten. */
reminder: Reminder | null;
/** Ergebnis von `GET /reminders/email-status`; `null` = unbekannt oder fehlgeschlagen (gilt als nicht moeglich). */
emailStatus: ReminderEmailStatus | null;
onClose: () => void;
/** Nach erfolgreichem Speichern (die Kachel laedt neu und meldet die Aenderung). */
onSaved: () => void;
@@ -42,7 +45,13 @@ const DESCRIPTION_MAX = 2000;
* eine Nutzerhandlung, die der Browser fuer die Frage akzeptiert. Nie beim
* Laden der Seite.
*/
export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: ReminderFormModalProps) {
export function ReminderFormModal({
reminder,
emailStatus,
onClose,
onSaved,
onStale,
}: ReminderFormModalProps) {
const t = useTranslations('widgets');
const initial = useRef(
reminder ? isoToLocalInputs(reminder.dueAt) : defaultNewReminderInputs(new Date()),
@@ -51,6 +60,7 @@ export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: Remin
const [time, setTime] = useState(initial.time);
const [title, setTitle] = useState(reminder?.title ?? '');
const [description, setDescription] = useState(reminder?.description ?? '');
const [emailEnabled, setEmailEnabled] = useState(reminder?.emailEnabled ?? false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const titleRef = useRef<HTMLInputElement>(null);
@@ -84,7 +94,16 @@ export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: Remin
setSaving(true);
try {
const input = { title: trimmedTitle, description: description.trim(), dueAt };
// `emailEnabled` nur senden, wenn es sich aendert oder beim Anlegen `true`
// ist: ein Server, der E-Mail nicht mehr anbietet, lehnt `true` mit 400
// ab — eine unveraenderte Alt-Einstellung darf das Speichern nicht kippen.
const emailChanged = reminder ? emailEnabled !== reminder.emailEnabled : emailEnabled;
const input = {
title: trimmedTitle,
description: description.trim(),
dueAt,
...(emailChanged ? { emailEnabled } : {}),
};
if (reminder) {
await updateReminder(reminder.id, input);
} else {
@@ -105,6 +124,17 @@ export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: Remin
}
};
// E-Mail moeglich: Versand eingerichtet UND Adresse im Konto. Unbekannt gilt
// als nicht moeglich. Ein bereits angehaktes Feld bleibt bedienbar, damit man
// es wieder abwaehlen kann.
const emailAvailable = emailStatus?.smtpConfigured === true && emailStatus.hasEmail;
const emailHint =
emailStatus === null || emailAvailable
? null
: !emailStatus.smtpConfigured
? t('reminder.emailNoSmtp')
: t('reminder.emailNoAddress');
if (typeof document === 'undefined') return null;
return createPortal(
@@ -175,6 +205,24 @@ export function ReminderFormModal({ reminder, onClose, onSaved, onStale }: Remin
/>
</div>
<div className="space-y-1">
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={emailEnabled}
disabled={!emailAvailable && !emailEnabled}
onChange={(e) => setEmailEnabled(e.target.checked)}
aria-describedby={emailHint ? 'reminder-email-hint' : undefined}
/>
{t('reminder.emailLabel')}
</label>
{emailHint && (
<p id="reminder-email-hint" className="text-xs text-muted-foreground">
{emailHint}
</p>
)}
</div>
{error && (
<div
role="alert"
@@ -16,6 +16,7 @@ vi.mock('@/lib/reminders-api', async () => {
updateReminder: vi.fn(),
snoozeReminder: vi.fn(),
deleteReminder: vi.fn(),
getReminderEmailStatus: vi.fn(),
};
});
@@ -23,6 +24,7 @@ import {
ReminderRequestError,
createReminder,
deleteReminder,
getReminderEmailStatus,
listReminders,
snoozeReminder,
updateReminder,
@@ -34,6 +36,7 @@ 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>;
const mockEmailStatus = getReminderEmailStatus as ReturnType<typeof vi.fn>;
function reminder(id: string, title: string, dueAt: string, description = '') {
return {
@@ -58,6 +61,8 @@ beforeEach(() => {
mockUpdate.mockReset();
mockSnooze.mockReset();
mockDelete.mockReset();
mockEmailStatus.mockReset();
mockEmailStatus.mockResolvedValue({ smtpConfigured: true, hasEmail: true });
requestPermission = vi.fn(async () => 'granted');
const ctor = vi.fn();
Object.assign(ctor, { permission: 'default', requestPermission });
@@ -307,3 +312,87 @@ describe('ReminderWidget — Faelligkeit ohne Neuladen', () => {
expect(screen.getByTestId('reminder-row').getAttribute('data-due')).toBe('true');
});
});
describe('ReminderWidget — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
async function openFormAndWaitForStatus() {
await openForm();
// Der Status wird beim Einhaengen der Kachel geladen
await waitFor(() => expect(mockEmailStatus).toHaveBeenCalledTimes(1));
return screen.getByLabelText('reminder.emailLabel') as HTMLInputElement;
}
it('ist bedienbar und wird als emailEnabled gesendet, wenn Versand und Adresse da sind', async () => {
mockList.mockResolvedValue([]);
mockCreate.mockResolvedValue(reminder('n', 'x', '2099-03-04T13:30:00.000Z'));
render(<ReminderWidget {...props} />);
const box = await openFormAndWaitForStatus();
await waitFor(() => expect(box.disabled).toBe(false));
fireEvent.click(box);
fillAndSubmit('Mit Mail');
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
expect(mockCreate.mock.calls[0][0].emailEnabled).toBe(true);
});
it('ohne E-Mail-Haken wird emailEnabled gar nicht gesendet', async () => {
mockList.mockResolvedValue([]);
mockCreate.mockResolvedValue(reminder('n', 'x', '2099-03-04T13:30:00.000Z'));
render(<ReminderWidget {...props} />);
await openFormAndWaitForStatus();
fillAndSubmit('Ohne Mail');
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('emailEnabled');
});
it('ist ausgegraut mit Erklaerung, wenn kein E-Mail-Versand eingerichtet ist', async () => {
mockEmailStatus.mockResolvedValue({ smtpConfigured: false, hasEmail: true });
mockList.mockResolvedValue([]);
render(<ReminderWidget {...props} />);
const box = await openFormAndWaitForStatus();
expect(await screen.findByText('reminder.emailNoSmtp')).toBeTruthy();
expect(box.disabled).toBe(true);
});
it('ist ausgegraut mit eigener Erklaerung, wenn das Konto keine Adresse hat', async () => {
mockEmailStatus.mockResolvedValue({ smtpConfigured: true, hasEmail: false });
mockList.mockResolvedValue([]);
render(<ReminderWidget {...props} />);
const box = await openFormAndWaitForStatus();
expect(await screen.findByText('reminder.emailNoAddress')).toBeTruthy();
expect(box.disabled).toBe(true);
});
it('gilt als nicht moeglich, solange der Status fehlt oder fehlschlaegt', async () => {
mockEmailStatus.mockRejectedValue(new Error('offline'));
mockList.mockResolvedValue([]);
render(<ReminderWidget {...props} />);
const box = await openFormAndWaitForStatus();
expect(box.disabled).toBe(true);
});
it('zeigt bei Zeilen mit emailEnabled ein Mail-Symbol, sonst nicht', async () => {
mockList.mockResolvedValue([
{ ...reminder('a', 'Mit', '2099-01-01T10:00:00.000Z'), emailEnabled: true },
reminder('b', 'Ohne', '2099-01-02T10:00:00.000Z'),
]);
render(<ReminderWidget {...props} />);
const rows = await screen.findAllByTestId('reminder-row');
expect(within(rows[0]).getByTestId('reminder-email-icon')).toBeTruthy();
expect(within(rows[1]).queryByTestId('reminder-email-icon')).toBeNull();
});
it('Bearbeiten einer Erinnerung mit angehakter Mail sendet emailEnabled nur bei Aenderung', async () => {
mockEmailStatus.mockResolvedValue({ smtpConfigured: false, hasEmail: true });
mockList.mockResolvedValue([
{ ...reminder('a', 'Alt', new Date(Date.now() + 3600_000).toISOString()), emailEnabled: true },
]);
mockUpdate.mockResolvedValue({});
render(<ReminderWidget {...props} />);
fireEvent.click(await screen.findByLabelText('reminder.edit'));
const box = screen.getByLabelText('reminder.emailLabel') as HTMLInputElement;
expect(box.checked).toBe(true);
expect(box.disabled).toBe(false); // angehakt bleibt bedienbar, zum Abwaehlen
fireEvent.click(screen.getByText('reminder.save'));
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
expect(mockUpdate.mock.calls[0][1]).not.toHaveProperty('emailEnabled');
});
});
@@ -7,8 +7,10 @@ import { type SnoozePreset, snoozeTarget } from '@/lib/reminder-time';
import {
REMINDERS_CHANGED_EVENT,
type Reminder,
type ReminderEmailStatus,
ReminderRequestError,
deleteReminder,
getReminderEmailStatus,
listReminders,
snoozeReminder,
} from '@/lib/reminders-api';
@@ -45,6 +47,8 @@ export function ReminderWidget(_props: WidgetProps) {
const [snoozeOpenId, setSnoozeOpenId] = useState<string | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
// Einmal je Einhaengen der Kachel geladen; unbekannt/fehlgeschlagen = E-Mail nicht moeglich.
const [emailStatus, setEmailStatus] = useState<ReminderEmailStatus | null>(null);
const refetch = useCallback(async () => {
try {
@@ -55,6 +59,18 @@ export function ReminderWidget(_props: WidgetProps) {
}
}, []);
useEffect(() => {
let cancelled = false;
getReminderEmailStatus()
.then((status) => {
if (!cancelled) setEmailStatus(status);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
void refetch();
const refresh = setInterval(() => void refetch(), REFRESH_MS);
@@ -160,8 +176,33 @@ export function ReminderWidget(_props: WidgetProps) {
: '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>
<div className="flex items-start gap-2">
<p className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">{r.title}</p>
{r.emailEnabled && (
<span
className="shrink-0 text-muted-foreground"
role="img"
aria-label={t('reminder.emailOn')}
title={t('reminder.emailOn')}
data-testid="reminder-email-icon"
>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m3 7 9 6 9-6" />
</svg>
</span>
)}
{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')}
@@ -308,6 +349,7 @@ export function ReminderWidget(_props: WidgetProps) {
{modal && (
<ReminderFormModal
reminder={modal.reminder}
emailStatus={emailStatus}
onClose={() => setModal(null)}
onSaved={afterMutation}
onStale={() => void refetch()}
+9
View File
@@ -3,6 +3,7 @@ import {
ReminderRequestError,
createReminder,
deleteReminder,
getReminderEmailStatus,
listReminders,
snoozeReminder,
updateReminder,
@@ -68,6 +69,14 @@ describe('reminders-api', () => {
expect(init.credentials).toBe('include');
});
it('getReminderEmailStatus ruft GET /reminders/email-status', async () => {
fetchMock.mockResolvedValue(ok({ smtpConfigured: true, hasEmail: false }));
await expect(getReminderEmailStatus()).resolves.toEqual({ smtpConfigured: true, hasEmail: false });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toMatch(/\/reminders\/email-status$/);
expect(init.credentials).toBe('include');
});
it('bei 404 und 409 tragen die Fehler den Status', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({}) });
await expect(deleteReminder('x')).rejects.toMatchObject({ status: 404 });
+12
View File
@@ -55,6 +55,8 @@ export interface ReminderInput {
description?: string;
/** ISO-8601-Zeitpunkt. */
dueAt: string;
/** Zusaetzlich per E-Mail erinnern; nur senden, wenn es sich aendert oder `true` ist. */
emailEnabled?: boolean;
}
export function createReminder(input: ReminderInput): Promise<Reminder> {
@@ -79,3 +81,13 @@ export function snoozeReminder(id: string, dueAt: string): Promise<Reminder> {
export function deleteReminder(id: string): Promise<{ deleted: true }> {
return request<{ deleted: true }>(`/reminders/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
/** Ob die E-Mail-Erinnerung fuer diesen Benutzer moeglich ist (Versand eingerichtet, Adresse vorhanden). */
export interface ReminderEmailStatus {
smtpConfigured: boolean;
hasEmail: boolean;
}
export function getReminderEmailStatus(): Promise<ReminderEmailStatus> {
return request<ReminderEmailStatus>('/reminders/email-status');
}
+4
View File
@@ -508,6 +508,10 @@
"alreadyDue": "Die Erinnerung ist inzwischen fällig und lässt sich nicht mehr bearbeiten.",
"notDue": "Die Erinnerung ist noch nicht fällig.",
"permissionDenied": "Ihr Browser blockiert Benachrichtigungen. Fällige Erinnerungen erscheinen dann nur hier in der Kachel.",
"emailLabel": "Zusätzlich per E-Mail erinnern",
"emailNoSmtp": "E-Mail-Erinnerungen sind nicht möglich, weil kein E-Mail-Versand eingerichtet ist. Bitte wenden Sie sich an Ihren Administrator.",
"emailNoAddress": "In Ihrem Konto ist keine E-Mail-Adresse hinterlegt.",
"emailOn": "Zusätzlich per E-Mail",
"notificationTitle": "Erinnerung: {title}"
},
"editShort": "Bearbeiten",
+4
View File
@@ -508,6 +508,10 @@
"alreadyDue": "The reminder is now due and can no longer be edited.",
"notDue": "The reminder is not due yet.",
"permissionDenied": "Your browser blocks notifications. Due reminders will then only appear here in the tile.",
"emailLabel": "Also remind me by e-mail",
"emailNoSmtp": "E-mail reminders are not possible because no e-mail delivery is set up. Please contact your administrator.",
"emailNoAddress": "Your account has no e-mail address on file.",
"emailOn": "Also by e-mail",
"notificationTitle": "Reminder: {title}"
},
"editShort": "Edit",