/** * 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) => Promise; /** 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).__TAURI_INTERNALS__; if (typeof internals !== 'object' || internals === null) return null; const invoke: unknown = (internals as Record).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 { 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 { 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 = {}; for (const [k, v] of Object.entries(parsed as Record)) { 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(); /** 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(fn: () => T | Promise): Promise { 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; }); }