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:
@@ -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>
|
||||
|
||||
@@ -17,12 +17,13 @@ vi.mock('@/lib/reminders-api', async () => {
|
||||
});
|
||||
|
||||
vi.mock('@/lib/reminder-notify', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/reminder-notify')>('@/lib/reminder-notify');
|
||||
const actual =
|
||||
await vi.importActual<typeof import('@/lib/reminder-notify')>('@/lib/reminder-notify');
|
||||
return { ...actual, showReminderNotification: vi.fn(async () => undefined) };
|
||||
});
|
||||
|
||||
import { showReminderNotification } from '@/lib/reminder-notify';
|
||||
import { ReminderRequestError, listReminders } from '@/lib/reminders-api';
|
||||
import { resetNotifiedMemoryForTests, showReminderNotification } from '@/lib/reminder-notify';
|
||||
import { listReminders, ReminderRequestError } from '@/lib/reminders-api';
|
||||
import { ReminderNotifier } from './reminder-notifier';
|
||||
|
||||
const mockList = listReminders as ReturnType<typeof vi.fn>;
|
||||
@@ -53,6 +54,7 @@ beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
window.localStorage.clear();
|
||||
resetNotifiedMemoryForTests();
|
||||
mockList.mockReset();
|
||||
mockShow.mockClear();
|
||||
});
|
||||
@@ -77,7 +79,9 @@ describe('ReminderNotifier', () => {
|
||||
});
|
||||
|
||||
it('meldet nichts, solange nichts faellig ist', async () => {
|
||||
mockList.mockResolvedValue([reminder({ dueAt: new Date(NOW.getTime() + 3600_000).toISOString() })]);
|
||||
mockList.mockResolvedValue([
|
||||
reminder({ dueAt: new Date(NOW.getTime() + 3600_000).toISOString() }),
|
||||
]);
|
||||
render(<ReminderNotifier />);
|
||||
await advance(30_000);
|
||||
expect(mockShow).not.toHaveBeenCalled();
|
||||
@@ -85,7 +89,10 @@ describe('ReminderNotifier', () => {
|
||||
|
||||
it('nimmt die Beschreibung als Text, gekuerzt auf 200 Zeichen', async () => {
|
||||
mockList.mockResolvedValue([
|
||||
reminder({ dueAt: new Date(NOW.getTime() - 1000).toISOString(), description: 'x'.repeat(300) }),
|
||||
reminder({
|
||||
dueAt: new Date(NOW.getTime() - 1000).toISOString(),
|
||||
description: 'x'.repeat(300),
|
||||
}),
|
||||
]);
|
||||
render(<ReminderNotifier />);
|
||||
await advance(0);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
isTauriWebview,
|
||||
remindersToNotify,
|
||||
requestBrowserPermissionOnce,
|
||||
resetNotifiedMemoryForTests,
|
||||
showReminderNotification,
|
||||
withNotifyLock,
|
||||
} from './reminder-notify';
|
||||
@@ -34,6 +35,7 @@ function installNotification(permission: NotificationPermission) {
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
resetNotifiedMemoryForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -65,6 +67,28 @@ describe('claimNotification', () => {
|
||||
const stored = JSON.parse(window.localStorage.getItem('tessera.reminders.notified') ?? '{}');
|
||||
expect(Object.keys(stored)).toEqual(['neu']);
|
||||
});
|
||||
|
||||
it('ohne nutzbaren localStorage (wirft beim Lesen und Schreiben) meldet es trotzdem nur einmal', () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('SecurityError');
|
||||
});
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('QuotaExceededError');
|
||||
});
|
||||
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW)).toBe(true);
|
||||
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW + 10_000)).toBe(false);
|
||||
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW + 3600_000)).toBe(false);
|
||||
// ein neuer Schluessel (verschoben) wird weiterhin gemeldet
|
||||
expect(claimNotification('r1|2026-09-29T12:10:00.000Z', NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('ohne window.localStorage (Zugriff wirft) meldet es trotzdem nur einmal', () => {
|
||||
vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => {
|
||||
throw new Error('SecurityError');
|
||||
});
|
||||
expect(claimNotification('k', NOW)).toBe(true);
|
||||
expect(claimNotification('k', NOW + 10_000)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remindersToNotify', () => {
|
||||
@@ -93,7 +117,9 @@ describe('showReminderNotification', () => {
|
||||
const invoke = installTauri();
|
||||
invoke.mockRejectedValue(new Error('nope'));
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
await expect(showReminderNotification({ title: 'T', body: 'B', tag: 'x' })).resolves.toBeUndefined();
|
||||
await expect(
|
||||
showReminderNotification({ title: 'T', body: 'B', tag: 'x' }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -30,12 +30,7 @@ export const CATCH_UP_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
/** Eintraege der lokalen Merkliste, die aelter sind, werden entfernt. */
|
||||
const NOTIFIED_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export type BrowserPermissionState =
|
||||
| 'desktop'
|
||||
| 'granted'
|
||||
| 'default'
|
||||
| 'denied'
|
||||
| 'unsupported';
|
||||
export type BrowserPermissionState = 'desktop' | 'granted' | 'default' | 'denied' | 'unsupported';
|
||||
|
||||
type TauriInvoke = (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
@@ -135,6 +130,19 @@ function readNotified(): Record<string, number> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merkliste im Arbeitsspeicher dieses Tabs. Sie greift IMMER, auch wenn der
|
||||
* localStorage gesperrt, voll oder im privaten Modus nicht nutzbar ist — sonst
|
||||
* meldete der Melder dieselbe faellige Erinnerung 24 Stunden lang bei jeder
|
||||
* Pruefung erneut. Der localStorage bleibt fuer die Abstimmung zwischen Tabs.
|
||||
*/
|
||||
const notifiedInMemory = new Map<string, number>();
|
||||
|
||||
/** Nur fuer Tests: leert die Merkliste im Arbeitsspeicher. */
|
||||
export function resetNotifiedMemoryForTests(): void {
|
||||
notifiedInMemory.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merkt sich `key` (`${id}|${dueAt}`) lokal. `true` nur beim ERSTEN Mal — so
|
||||
* meldet jeder Client jede Faelligkeit hoechstens einmal, auch ueber mehrere
|
||||
@@ -143,16 +151,22 @@ function readNotified(): Record<string, number> {
|
||||
* aelter als 7 Tage werden dabei entfernt.
|
||||
*/
|
||||
export function claimNotification(key: string, nowMs: number): boolean {
|
||||
for (const [k, at] of notifiedInMemory) {
|
||||
if (nowMs - at > NOTIFIED_TTL_MS) notifiedInMemory.delete(k);
|
||||
}
|
||||
const record = readNotified();
|
||||
for (const [k, at] of Object.entries(record)) {
|
||||
if (nowMs - at > NOTIFIED_TTL_MS) delete record[k];
|
||||
}
|
||||
const first = !(key in record);
|
||||
if (first) record[key] = nowMs;
|
||||
const first = !(key in record) && !notifiedInMemory.has(key);
|
||||
if (first) {
|
||||
record[key] = nowMs;
|
||||
notifiedInMemory.set(key, nowMs);
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(NOTIFIED_KEY, JSON.stringify(record));
|
||||
} catch {
|
||||
/* Speicher voll oder gesperrt: dann eben ohne Merkliste */
|
||||
/* Speicher voll oder gesperrt: dann greift nur die Merkliste im Arbeitsspeicher */
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
defaultNewReminderInputs,
|
||||
isoToLocalInputs,
|
||||
isSameLocalDay,
|
||||
localInputsToIso,
|
||||
snoozeTarget,
|
||||
} from './reminder-time';
|
||||
@@ -50,7 +51,9 @@ describe('snoozeTarget (E-05)', () => {
|
||||
});
|
||||
|
||||
it('morgen = urspruengliche Ortszeit am naechsten Tag', () => {
|
||||
expect(snoozeTarget('tomorrow', original, now).getTime()).toBe(local(2026, 9, 30, 14, 0).getTime());
|
||||
expect(snoozeTarget('tomorrow', original, now).getTime()).toBe(
|
||||
local(2026, 9, 30, 14, 0).getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it('morgen ueber das Monatsende', () => {
|
||||
@@ -67,3 +70,24 @@ describe('snoozeTarget (E-05)', () => {
|
||||
expect(target.getTime()).toBe(local(2026, 9, 30, 8, 15).getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('snoozeTarget kurz nach Mitternacht / isSameLocalDay', () => {
|
||||
it('faellig gestern 23:50, verschoben heute 00:10 -> heute 23:50 (heute, nicht morgen)', () => {
|
||||
const n = local(2026, 9, 30, 0, 10);
|
||||
const target = snoozeTarget('tomorrow', local(2026, 9, 29, 23, 50), n);
|
||||
expect(target.getTime()).toBe(local(2026, 9, 30, 23, 50).getTime());
|
||||
expect(isSameLocalDay(target, n)).toBe(true);
|
||||
});
|
||||
|
||||
it('der Normalfall liegt am naechsten Tag', () => {
|
||||
const n = local(2026, 9, 29, 14, 5);
|
||||
const target = snoozeTarget('tomorrow', local(2026, 9, 29, 14, 0), n);
|
||||
expect(isSameLocalDay(target, n)).toBe(false);
|
||||
});
|
||||
|
||||
it('isSameLocalDay vergleicht den Kalendertag in Ortszeit', () => {
|
||||
expect(isSameLocalDay(local(2026, 9, 30, 0, 0), local(2026, 9, 30, 23, 59))).toBe(true);
|
||||
expect(isSameLocalDay(local(2026, 9, 30, 23, 59), local(2026, 10, 1, 0, 0))).toBe(false);
|
||||
expect(isSameLocalDay(local(2026, 9, 30), local(2025, 9, 30))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,7 +45,10 @@ export type SnoozePreset = '10m' | '1h' | 'tomorrow';
|
||||
* 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.
|
||||
* landet sie beim naechsten kuenftigen Vorkommen dieser Uhrzeit. Das kann auch
|
||||
* HEUTE sein (faellig gestern 23:50, verschoben heute 00:10 -> heute 23:50) —
|
||||
* die Beschriftung leitet „heute“/„morgen“ deshalb aus dem Ergebnis ab
|
||||
* (`isSameLocalDay`), nicht aus dem Namen der Option.
|
||||
*/
|
||||
export function snoozeTarget(preset: SnoozePreset, originalDueAt: Date, now: Date): Date {
|
||||
if (preset === '10m') return new Date(now.getTime() + 10 * 60_000);
|
||||
@@ -56,3 +59,12 @@ export function snoozeTarget(preset: SnoozePreset, originalDueAt: Date, now: Dat
|
||||
} while (d.getTime() <= now.getTime());
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Liegen beide Zeitpunkte am selben Kalendertag (Ortszeit)? */
|
||||
export function isSameLocalDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user