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
+27 -1
View File
@@ -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);
});
+23 -9
View File
@@ -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;
}
+25 -1
View File
@@ -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);
});
});
+13 -1
View File
@@ -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()
);
}