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:
@@ -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