c2e4467dd8
- 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>
193 lines
7.1 KiB
TypeScript
193 lines
7.1 KiB
TypeScript
/**
|
|
* Benachrichtigungen fuer Erinnerungen (quick-260929-if2) — reine Funktionen
|
|
* ohne React, damit Melder und Kachel sie gemeinsam nutzen und testen koennen.
|
|
*
|
|
* Zwei Wege, je nach Umgebung:
|
|
* - Desktop-App (Tauri-Webview): der Aufruf geht DIREKT an das Benachrichtigungs-
|
|
* Plugin (`plugin:notification|notify`). Die Rust-Seite erteilt dafuer zur
|
|
* Laufzeit genau diese eine Berechtigung fuer die gespeicherte Serveradresse
|
|
* (E-01, `grant_server_notifications` in apps/desktop/src-tauri/src/lib.rs).
|
|
* - Browser: die Web-Benachrichtigung (`new Notification(...)`), nur mit
|
|
* erteilter Erlaubnis.
|
|
*
|
|
* WARUM DER TAURI-WEG NIE `Notification.permission` LIEST (E-01): das Plugin
|
|
* ersetzt `window.Notification` in der Webview durch einen Ersatz, der
|
|
* denselben IPC-Aufruf macht. Dieser Ersatz meldet unter Windows bei JEDEM
|
|
* Seitenaufruf `permission = "denied"`, bis einmal `requestPermission()`
|
|
* gelaufen ist. Wer darauf hoeren wuerde, unterdrueckte im Desktop-Client
|
|
* jede Meldung. Deshalb ruft der Tauri-Zweig `invoke` direkt und fragt keine
|
|
* Erlaubnis ab.
|
|
*/
|
|
|
|
import type { Reminder } from './reminders-api';
|
|
|
|
const PERMISSION_ASKED_KEY = 'tessera.reminders.permissionAsked';
|
|
const NOTIFIED_KEY = 'tessera.reminders.notified';
|
|
const LOCK_NAME = 'tessera-reminder-notify';
|
|
|
|
/** E-03: so lange nach der Faelligkeit wird noch nachgemeldet (ein Client, der spaet oeffnet). */
|
|
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';
|
|
|
|
type TauriInvoke = (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
|
|
|
/** Die `invoke`-Funktion der Tauri-Webview, sonst `null` (Browser). */
|
|
function tauriInvoke(): TauriInvoke | null {
|
|
if (typeof window === 'undefined') return null;
|
|
const internals: unknown = (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
|
|
if (typeof internals !== 'object' || internals === null) return null;
|
|
const invoke: unknown = (internals as Record<string, unknown>).invoke;
|
|
return typeof invoke === 'function' ? (invoke as TauriInvoke) : null;
|
|
}
|
|
|
|
/** `true` in der Desktop-App (Tauri-Webview), `false` im normalen Browser. */
|
|
export function isTauriWebview(): boolean {
|
|
return tauriInvoke() !== null;
|
|
}
|
|
|
|
function hasWebNotifications(): boolean {
|
|
return typeof Notification !== 'undefined';
|
|
}
|
|
|
|
/** Zustand der Benachrichtigungs-Erlaubnis; in der Desktop-App immer `desktop`. */
|
|
export function browserPermissionState(): BrowserPermissionState {
|
|
if (isTauriWebview()) return 'desktop';
|
|
if (!hasWebNotifications()) return 'unsupported';
|
|
return Notification.permission;
|
|
}
|
|
|
|
function readFlag(key: string): boolean {
|
|
try {
|
|
return window.localStorage.getItem(key) !== null;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fragt die Browser-Erlaubnis HOECHSTENS EINMAL (D-04). Aufzurufen aus einer
|
|
* Nutzerhandlung (erstes Anlegen im Widget), nie beim Laden der Seite. Nichts
|
|
* passiert in der Desktop-App, ohne Web-Benachrichtigungen, bei bereits
|
|
* getroffener Entscheidung oder wenn die Frage schon einmal gestellt wurde.
|
|
*/
|
|
export function requestBrowserPermissionOnce(): void {
|
|
if (isTauriWebview() || !hasWebNotifications()) return;
|
|
if (Notification.permission !== 'default') return;
|
|
if (readFlag(PERMISSION_ASKED_KEY)) return;
|
|
try {
|
|
window.localStorage.setItem(PERMISSION_ASKED_KEY, '1');
|
|
} catch {
|
|
/* ohne Speicher lieber fragen als nie */
|
|
}
|
|
try {
|
|
void Notification.requestPermission();
|
|
} catch {
|
|
/* aeltere Browser: kein Versprechen, keine Folge */
|
|
}
|
|
}
|
|
|
|
export interface ReminderNotification {
|
|
title: string;
|
|
body: string;
|
|
tag: string;
|
|
}
|
|
|
|
/** Zeigt eine Benachrichtigung — Desktop ueber das Plugin, Browser nur mit Erlaubnis. */
|
|
export async function showReminderNotification(n: ReminderNotification): Promise<void> {
|
|
const invoke = tauriInvoke();
|
|
if (invoke) {
|
|
try {
|
|
await invoke('plugin:notification|notify', { options: { title: n.title, body: n.body } });
|
|
} catch (err) {
|
|
console.warn('[reminders] Desktop-Benachrichtigung fehlgeschlagen', err);
|
|
}
|
|
return;
|
|
}
|
|
if (!hasWebNotifications() || Notification.permission !== 'granted') return;
|
|
try {
|
|
new Notification(n.title, { body: n.body, tag: n.tag });
|
|
} catch (err) {
|
|
console.warn('[reminders] Browser-Benachrichtigung fehlgeschlagen', err);
|
|
}
|
|
}
|
|
|
|
function readNotified(): Record<string, number> {
|
|
try {
|
|
const raw = window.localStorage.getItem(NOTIFIED_KEY);
|
|
if (!raw) return {};
|
|
const parsed: unknown = JSON.parse(raw);
|
|
if (typeof parsed !== 'object' || parsed === null) return {};
|
|
const out: Record<string, number> = {};
|
|
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
|
if (typeof v === 'number') out[k] = v;
|
|
}
|
|
return out;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* Tabs desselben Browsers hinweg (gemeinsamer localStorage). Nach einem
|
|
* Verschieben aendert sich `dueAt` und damit der Schluessel. Eintraege
|
|
* 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) && !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 greift nur die Merkliste im Arbeitsspeicher */
|
|
}
|
|
return first;
|
|
}
|
|
|
|
/** Fuehrt `fn` unter einer Web-Sperre aus (mehrere Tabs nacheinander), sonst direkt. */
|
|
export async function withNotifyLock<T>(fn: () => T | Promise<T>): Promise<T> {
|
|
const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined;
|
|
if (locks && typeof locks.request === 'function') {
|
|
return locks.request(LOCK_NAME, async () => fn());
|
|
}
|
|
return fn();
|
|
}
|
|
|
|
/**
|
|
* Erinnerungen, die JETZT gemeldet werden sollen: faellig (`dueAt <= now`,
|
|
* D-02: nie vorher) und nicht aelter als 24 Stunden (E-03).
|
|
*/
|
|
export function remindersToNotify(reminders: Reminder[], nowMs: number): Reminder[] {
|
|
return reminders.filter((r) => {
|
|
const due = new Date(r.dueAt).getTime();
|
|
return due <= nowMs && due > nowMs - CATCH_UP_WINDOW_MS;
|
|
});
|
|
}
|