fix(reminders): keine Dauermeldung ohne localStorage, Mail nach Zeitaenderung, null-Pruefung

- Merkliste zusaetzlich im Arbeitsspeicher (sonst alle 10 s dieselbe Meldung)
- Aendern der Faelligkeit atomar gegen gleichzeitiges Faelligwerden, setzt Mail-Spur zurueck
- UpdateReminderDto lehnt null ab (400 statt 500)
- keine Mails an deaktivierte Benutzer
- Spaeter erinnern beschriftet heute/morgen nach dem berechneten Zeitpunkt
- Bearbeiten schickt dueAt nur bei geaenderter Zeit

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 03:20:16 +02:00
parent be1e0035e0
commit c2e4467dd8
14 changed files with 442 additions and 78 deletions
@@ -6,10 +6,10 @@ import { createPortal } from 'react-dom';
import { requestBrowserPermissionOnce } from '@/lib/reminder-notify';
import { defaultNewReminderInputs, isoToLocalInputs, localInputsToIso } from '@/lib/reminder-time';
import {
createReminder,
type Reminder,
type ReminderEmailStatus,
ReminderRequestError,
createReminder,
updateReminder,
} from '@/lib/reminders-api';
@@ -85,9 +85,15 @@ export function ReminderFormModal({
setError(t('reminder.titleRequired'));
return;
}
const dueAt = localInputsToIso(date, time);
// Beim Bearbeiten nur senden, wenn Datum oder Uhrzeit wirklich geaendert
// wurden: das Formular kennt keine Sekunden, ein unveraendertes Feld wuerde
// die Faelligkeit sonst um bis zu 59 s verschieben — und kurz vor der
// Faelligkeit faelschlich „in der Vergangenheit“ melden. Ist sie inzwischen
// faellig, antwortet der Server ohnehin mit 409 (`alreadyDue`).
const dueChanged = !reminder || date !== initial.date || time !== initial.time;
const dueAt = dueChanged ? localInputsToIso(date, time) : undefined;
// Spiegel der Serverregel: die Faelligkeit muss in der Zukunft liegen.
if (dueAt === null || new Date(dueAt).getTime() <= Date.now()) {
if (dueChanged && (!dueAt || new Date(dueAt).getTime() <= Date.now())) {
setError(t('reminder.pastError'));
return;
}
@@ -98,16 +104,15 @@ export function ReminderFormModal({
// 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 = {
const common = {
title: trimmedTitle,
description: description.trim(),
dueAt,
...(emailChanged ? { emailEnabled } : {}),
};
if (reminder) {
await updateReminder(reminder.id, input);
} else {
await createReminder(input);
await updateReminder(reminder.id, { ...common, ...(dueAt ? { dueAt } : {}) });
} else if (dueAt) {
await createReminder({ ...common, dueAt });
}
onSaved();
onClose();
@@ -21,11 +21,11 @@ vi.mock('@/lib/reminders-api', async () => {
});
import {
ReminderRequestError,
createReminder,
deleteReminder,
getReminderEmailStatus,
listReminders,
ReminderRequestError,
snoozeReminder,
updateReminder,
} from '@/lib/reminders-api';
@@ -79,7 +79,9 @@ async function openForm() {
function fillAndSubmit(title: string) {
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: title } });
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: '2099-03-04' } });
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), {
target: { value: '2099-03-04' },
});
fireEvent.change(screen.getByLabelText('reminder.timeLabel'), { target: { value: '14:30' } });
fireEvent.click(screen.getByText('reminder.save'));
}
@@ -146,7 +148,9 @@ describe('ReminderWidget', () => {
render(<ReminderWidget {...props} />);
await openForm();
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: 'x' } });
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: '2001-01-01' } });
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), {
target: { value: '2001-01-01' },
});
fireEvent.click(screen.getByText('reminder.save'));
expect(await screen.findByText('reminder.pastError')).toBeTruthy();
expect(mockCreate).not.toHaveBeenCalled();
@@ -260,6 +264,38 @@ describe('ReminderWidget — faellig und Aktionen (D-03)', () => {
expect(requestPermission).not.toHaveBeenCalled();
});
it('Bearbeiten ohne Aenderung von Datum/Uhrzeit sendet kein dueAt (keine Sekunden-Verschiebung)', async () => {
// Faellig in 30 s, mit Sekunden: auf Minuten gekuerzt laege sie in der Vergangenheit.
const due = new Date(Date.now() + 30_000);
due.setSeconds(due.getSeconds(), 500);
mockList.mockResolvedValue([reminder('a', 'Alt', due.toISOString())]);
mockUpdate.mockResolvedValue({});
render(<ReminderWidget {...props} />);
fireEvent.click(await screen.findByLabelText('reminder.edit'));
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: 'Neu' } });
fireEvent.click(screen.getByText('reminder.save'));
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
expect(screen.queryByText('reminder.pastError')).toBeNull();
expect(mockUpdate.mock.calls[0][1]).not.toHaveProperty('dueAt');
expect(mockUpdate.mock.calls[0][1].title).toBe('Neu');
});
it('Bearbeiten mit geaenderter Uhrzeit sendet das neue dueAt', async () => {
const due = new Date(Date.now() + 2 * 3600_000);
mockList.mockResolvedValue([reminder('a', 'Alt', due.toISOString())]);
mockUpdate.mockResolvedValue({});
render(<ReminderWidget {...props} />);
fireEvent.click(await screen.findByLabelText('reminder.edit'));
const next = new Date(Date.now() + 26 * 3600_000);
const pad = (n: number) => String(n).padStart(2, '0');
const nextDate = `${next.getFullYear()}-${pad(next.getMonth() + 1)}-${pad(next.getDate())}`;
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: nextDate } });
fireEvent.change(screen.getByLabelText('reminder.timeLabel'), { target: { value: '09:15' } });
fireEvent.click(screen.getByText('reminder.save'));
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
expect(mockUpdate.mock.calls[0][1].dueAt).toBe(new Date(`${nextDate}T09:15`).toISOString());
});
it('409 beim Verschieben zeigt den Hinweis und laedt neu', async () => {
mockList.mockResolvedValue([reminder('a', 'Faellig', iso(-60_000))]);
mockSnooze.mockRejectedValue(new ReminderRequestError(409));
@@ -313,6 +349,27 @@ describe('ReminderWidget — Faelligkeit ohne Neuladen', () => {
});
});
describe('ReminderWidget — Spaeter erinnern kurz nach Mitternacht', () => {
afterEach(() => {
vi.useRealTimers();
});
it('faellig gestern 23:50, jetzt 00:10: die Option heisst „Heute“ und verschiebt auf heute 23:50', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
vi.setSystemTime(new Date(2026, 8, 30, 0, 10));
mockList.mockResolvedValue([
reminder('a', 'Spaet', new Date(2026, 8, 29, 23, 50).toISOString()),
]);
mockSnooze.mockResolvedValue({});
render(<ReminderWidget {...props} />);
fireEvent.click(await screen.findByText('reminder.snooze'));
expect(screen.queryByText('reminder.snoozeTomorrow')).toBeNull();
fireEvent.click(await screen.findByText('reminder.snoozeToday'));
await waitFor(() => expect(mockSnooze).toHaveBeenCalledTimes(1));
expect(mockSnooze.mock.calls[0][1]).toBe(new Date(2026, 8, 30, 23, 50).toISOString());
});
});
describe('ReminderWidget — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
async function openFormAndWaitForStatus() {
await openForm();
@@ -383,7 +440,10 @@ describe('ReminderWidget — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
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 },
{
...reminder('a', 'Alt', new Date(Date.now() + 3600_000).toISOString()),
emailEnabled: true,
},
]);
mockUpdate.mockResolvedValue({});
render(<ReminderWidget {...props} />);
@@ -3,15 +3,15 @@
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 { isSameLocalDay, type SnoozePreset, snoozeTarget } from '@/lib/reminder-time';
import {
deleteReminder,
getReminderEmailStatus,
listReminders,
REMINDERS_CHANGED_EVENT,
type Reminder,
type ReminderEmailStatus,
ReminderRequestError,
deleteReminder,
getReminderEmailStatus,
listReminders,
snoozeReminder,
} from '@/lib/reminders-api';
import type { WidgetProps } from '../widget-registry';
@@ -107,7 +107,9 @@ export function ReminderWidget(_props: WidgetProps) {
/** 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'),
err instanceof ReminderRequestError && err.status === 409
? t(conflictKey)
: t('reminder.saveError'),
);
void refetch();
};
@@ -134,6 +136,16 @@ export function ReminderWidget(_props: WidgetProps) {
}
};
// „Heute“ oder „Morgen“ aus dem tatsaechlich berechneten Zeitpunkt: kurz nach
// Mitternacht liegt das naechste Vorkommen der Uhrzeit noch heute.
const tomorrowLabel = (r: Reminder) => {
const target = snoozeTarget('tomorrow', new Date(r.dueAt), new Date(now));
const time = timeOnly.format(target);
return isSameLocalDay(target, new Date(now))
? t('reminder.snoozeToday', { time })
: t('reminder.snoozeTomorrow', { time });
};
const remove = async (id: string) => {
setNotice(null);
setConfirmDeleteId(null);
@@ -177,7 +189,9 @@ export function ReminderWidget(_props: WidgetProps) {
}
>
<div className="flex items-start gap-2">
<p className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">{r.title}</p>
<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"
@@ -244,8 +258,7 @@ export function ReminderWidget(_props: WidgetProps) {
>
{preset === '10m' && t('reminder.snooze10m')}
{preset === '1h' && t('reminder.snooze1h')}
{preset === 'tomorrow' &&
t('reminder.snoozeTomorrow', { time: timeOnly.format(new Date(r.dueAt)) })}
{preset === 'tomorrow' && tomorrowLabel(r)}
</button>
))}
</div>