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:
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user