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
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import {
defaultNewReminderInputs,
isoToLocalInputs,
localInputsToIso,
snoozeTarget,
} from './reminder-time';
// Alle Zeitpunkte in Ortszeit gebildet (`new Date(y, m, d, h, min)`), damit die
// Tests in jeder Zeitzone dasselbe pruefen.
const local = (y: number, m: number, d: number, h = 0, min = 0) => new Date(y, m - 1, d, h, min);
describe('localInputsToIso / isoToLocalInputs', () => {
it('laufen im Kreis', () => {
const iso = localInputsToIso('2026-10-05', '14:30');
expect(iso).toBe(local(2026, 10, 5, 14, 30).toISOString());
expect(isoToLocalInputs(iso as string)).toEqual({ date: '2026-10-05', time: '14:30' });
});
it('liefert null bei unvollstaendiger oder ungueltiger Eingabe', () => {
expect(localInputsToIso('', '14:30')).toBeNull();
expect(localInputsToIso('2026-10-05', '')).toBeNull();
expect(localInputsToIso('2026-13-45', '14:30')).toBeNull();
});
});
describe('defaultNewReminderInputs', () => {
it('ist die naechste volle Stunde', () => {
expect(defaultNewReminderInputs(local(2026, 9, 29, 13, 20))).toEqual({
date: '2026-09-29',
time: '14:00',
});
});
it('rollt ueber Mitternacht und Monatsende', () => {
expect(defaultNewReminderInputs(local(2026, 9, 30, 23, 30))).toEqual({
date: '2026-10-01',
time: '00:00',
});
});
});
describe('snoozeTarget (E-05)', () => {
const now = local(2026, 9, 29, 14, 5);
const original = local(2026, 9, 29, 14, 0);
it('10m und 1h zaehlen ab jetzt, nicht ab der alten Faelligkeit', () => {
expect(snoozeTarget('10m', original, now).getTime()).toBe(now.getTime() + 10 * 60_000);
expect(snoozeTarget('1h', original, now).getTime()).toBe(now.getTime() + 3600_000);
});
it('morgen = urspruengliche Ortszeit am naechsten Tag', () => {
expect(snoozeTarget('tomorrow', original, now).getTime()).toBe(local(2026, 9, 30, 14, 0).getTime());
});
it('morgen ueber das Monatsende', () => {
const n = local(2026, 9, 30, 15, 0);
expect(snoozeTarget('tomorrow', local(2026, 9, 30, 14, 0), n).getTime()).toBe(
local(2026, 10, 1, 14, 0).getTime(),
);
});
it('eine tagelang ueberfaellige Erinnerung landet beim naechsten kuenftigen Vorkommen', () => {
const old = local(2026, 9, 25, 8, 15);
const target = snoozeTarget('tomorrow', old, now);
expect(target.getTime()).toBeGreaterThan(now.getTime());
expect(target.getTime()).toBe(local(2026, 9, 30, 8, 15).getTime());
});
});
+29
View File
@@ -27,3 +27,32 @@ export function defaultNewReminderInputs(now: Date): { date: string; time: strin
time: `${pad(next.getHours())}:${pad(next.getMinutes())}`,
};
}
/** Zeitpunkt aus ISO-Zeichenkette als Datums- und Zeitfeld (Ortszeit) fuer das Formular. */
export function isoToLocalInputs(iso: string): { date: string; time: string } {
const d = new Date(iso);
return {
date: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`,
time: `${pad(d.getHours())}:${pad(d.getMinutes())}`,
};
}
export type SnoozePreset = '10m' | '1h' | 'tomorrow';
/**
* Neuer Zeitpunkt fuer „Spaeter erinnern“ (D-03, E-05). `10m` und `1h` zaehlen
* ab JETZT, nicht ab der alten Faelligkeit. `tomorrow` nimmt die urspruengliche
* Ortszeit und geht einen Kalendertag weiter (`setDate(+1)` ist sommerzeit-
* sicher), so oft, bis das Ergebnis in der Zukunft liegt: faellig heute 14:00,
* verschoben um 14:05 -> morgen 14:00; war die Erinnerung schon Tage ueberfaellig,
* landet sie beim naechsten kuenftigen Vorkommen dieser Uhrzeit.
*/
export function snoozeTarget(preset: SnoozePreset, originalDueAt: Date, now: Date): Date {
if (preset === '10m') return new Date(now.getTime() + 10 * 60_000);
if (preset === '1h') return new Date(now.getTime() + 60 * 60_000);
const d = new Date(originalDueAt);
do {
d.setDate(d.getDate() + 1);
} while (d.getTime() <= now.getTime());
return d;
}
+43 -1
View File
@@ -1,5 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ReminderRequestError, createReminder, listReminders } from './reminders-api';
import {
ReminderRequestError,
createReminder,
deleteReminder,
listReminders,
snoozeReminder,
updateReminder,
} from './reminders-api';
const fetchMock = vi.fn();
@@ -33,6 +40,41 @@ describe('reminders-api', () => {
expect(JSON.parse(init.body)).toEqual({ title: 'a', dueAt: '2099-01-01T10:00:00.000Z' });
});
it('updateReminder schickt PATCH an /reminders/:id', async () => {
fetchMock.mockResolvedValue(ok({ id: 'r 1' }));
await updateReminder('r 1', { title: 'neu' });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toMatch(/\/reminders\/r%201$/);
expect(init.method).toBe('PATCH');
expect(init.credentials).toBe('include');
expect(JSON.parse(init.body)).toEqual({ title: 'neu' });
});
it('snoozeReminder schickt POST /reminders/:id/snooze mit dueAt', async () => {
fetchMock.mockResolvedValue(ok({ id: 'r1' }));
await snoozeReminder('r1', '2099-01-01T10:00:00.000Z');
const [url, init] = fetchMock.mock.calls[0];
expect(url).toMatch(/\/reminders\/r1\/snooze$/);
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({ dueAt: '2099-01-01T10:00:00.000Z' });
});
it('deleteReminder schickt DELETE an /reminders/:id', async () => {
fetchMock.mockResolvedValue(ok({ deleted: true }));
await expect(deleteReminder('r1')).resolves.toEqual({ deleted: true });
const [url, init] = fetchMock.mock.calls[0];
expect(url).toMatch(/\/reminders\/r1$/);
expect(init.method).toBe('DELETE');
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 });
fetchMock.mockResolvedValue({ ok: false, status: 409, json: async () => ({}) });
await expect(snoozeReminder('x', 'y')).rejects.toMatchObject({ status: 409 });
});
it('ein Status ausserhalb 2xx wirft ReminderRequestError mit diesem Status', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 401, json: async () => ({}) });
await expect(listReminders()).rejects.toMatchObject({ status: 401 });
+19
View File
@@ -60,3 +60,22 @@ export interface ReminderInput {
export function createReminder(input: ReminderInput): Promise<Reminder> {
return request<Reminder>('/reminders', jsonInit('POST', input));
}
export type ReminderPatch = Partial<ReminderInput>;
export function updateReminder(id: string, patch: ReminderPatch): Promise<Reminder> {
return request<Reminder>(`/reminders/${encodeURIComponent(id)}`, jsonInit('PATCH', patch));
}
/** „Spaeter erinnern“: neue Faelligkeit (ISO), nur bei einer faelligen Erinnerung. */
export function snoozeReminder(id: string, dueAt: string): Promise<Reminder> {
return request<Reminder>(
`/reminders/${encodeURIComponent(id)}/snooze`,
jsonInit('POST', { dueAt }),
);
}
/** Loescht die Erinnerung — bedient „Loeschen“ (vorher) und „Erledigt“ (nachher). */
export function deleteReminder(id: string): Promise<{ deleted: true }> {
return request<{ deleted: true }>(`/reminders/${encodeURIComponent(id)}`, { method: 'DELETE' });
}