import { defineStore } from "pinia"; import { ref } from "vue"; import { repo } from "../adapters"; import { isDesktop, onReminderDue } from "../desktop/bridge"; import router from "../router"; import { useUiStore } from "./ui"; import type { Note } from "./notes"; // Reminder delivery. In a browser it is foreground only, with no Web Push or service // worker: while the tab is open, poll for due reminders and surface each once as a // toast (and, if the user opted in, a page-context OS notification). // // The desktop doesn't poll here. Its Rust worker reads the local store, decides what // is due, raises the system notification itself when the window isn't in front, and // tells this page so it can show the toast. In WebKitGTK the page's `Notification` // goes nowhere and its timer stops with the window. const POLL_MS = 45_000; const RECENT_MS = 15 * 60 * 1000; // on first load, only announce reminders due this recently export const useReminderStore = defineStore("reminders", () => { const desktop = isDesktop(); // The desktop raises its own system notifications; there is nothing to opt into. const osSupported = !desktop && typeof Notification !== "undefined"; const osEnabled = ref(osSupported && Notification.permission === "granted"); // note id → the remind_at we already fired, so each occurrence notifies at most once. const notified = new Map(); let timer: ReturnType | undefined; // The desktop's subscription to its reminder worker, held as the promise so stop() // can undo one whose registration hasn't finished yet. let listening: Promise<() => void> | undefined; let primed = false; // Single owner of the reminders endpoint — the RemindersView reads its list through // this too, so the URL + response shape live in one place. async function fetchReminders(): Promise { return repo.notes.reminders(); } /** Show the in-app toast for a due reminder; returns the title it showed. */ function toast(id: string, displayTitle: string): string { const title = displayTitle || "Reminder"; useUiStore().showToast(`⏰ ${title}`, { label: "Open", run: () => void router.push({ path: "/", query: { open: id } }), }); return title; } async function check(): Promise { let notes: Note[]; try { notes = await fetchReminders(); } catch { return; } const now = Date.now(); for (const n of notes) { if (!n.remind_at) continue; const due = Date.parse(n.remind_at); if (due > now || notified.get(n.id) === n.remind_at) continue; notified.set(n.id, n.remind_at); // Silently prime a stale backlog on first load; only announce recently-due ones. if (!primed && now - due > RECENT_MS) continue; const title = toast(n.id, n.display_title); if (osEnabled.value) { try { new Notification("Inkwell reminder", { body: title }); } catch { /* notifications unavailable — the toast still fired */ } } } primed = true; } function start(): void { if (desktop) { if (!listening) listening = onReminderDue((r) => void toast(r.id, r.title)); return; } if (timer) return; void check(); timer = setInterval(() => void check(), POLL_MS); } function stop(): void { if (timer) clearInterval(timer); timer = undefined; void listening?.then((off) => off()); listening = undefined; } async function enableOs(): Promise { if (!osSupported) return; osEnabled.value = (await Notification.requestPermission()) === "granted"; } return { osSupported, osEnabled, start, stop, enableOs, fetchReminders }; });