"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { formatTimeDue, isReminderDue } from "@/lib/time-of-day"; import type { ScheduledBoardDTO } from "@/types/scheduled-todo"; // Separate from the panel's own collapsed-state key (scheduled-panel-provider.tsx). const STORAGE_KEY = "organize:scheduled-notifications-enabled"; /** * Browser `Notification`s for scheduled to-dos, fired per each one's own * "Remind me" lead time (see lib/time-of-day.ts's REMIND_OPTIONS) -- which * may be before its time due, not necessarily at the same moment it's * promoted into Overdue (see ScheduledPanel; that's driven by timeDue * itself, unaffected by the reminder offset). This only reaches the user * while some copy of this tab is open somewhere (foreground or * backgrounded); there's no service worker `push` handler behind it, so * nothing fires once the tab itself is closed. That would need real Web * Push infrastructure (VAPID keys, a subscription table, a server-side * sender) -- a bigger step this intentionally doesn't take. */ export function useScheduledNotifications(board: ScheduledBoardDTO | null, now: Date) { // Whether the user has opted in *within the app* -- distinct from the // browser's own permission, which can only be granted or revoked through // its own UI, never by us. Same hydration-safe pattern as // ScheduledPanelProvider: default off on both server and first client // render, then read the persisted value (and the live permission) right // after mount. const [enabled, setEnabled] = useState(false); const [permission, setPermission] = useState("unsupported"); useEffect(() => { if (!("Notification" in window)) return; setPermission(Notification.permission); const stored = localStorage.getItem(STORAGE_KEY); // Only actually on if the browser still agrees -- a permission the user // revoked from browser settings since last time shouldn't look enabled. setEnabled(stored === "true" && Notification.permission === "granted"); }, []); const requestEnable = useCallback(async () => { if (!("Notification" in window)) return false; let result = Notification.permission; if (result === "default") { result = await Notification.requestPermission(); setPermission(result); } const granted = result === "granted"; if (granted) { localStorage.setItem(STORAGE_KEY, "true"); setEnabled(true); } return granted; }, []); const disable = useCallback(() => { localStorage.setItem(STORAGE_KEY, "false"); setEnabled(false); }, []); // Occurrences already notified about this session, so the once-a-minute // tick in ScheduledPanel doesn't re-fire the same notification every time // it re-checks. Keyed the same way as everywhere else in this panel; // never pruned since occurrenceDate makes each key unique per calendar // day anyway, and the set only lives as long as the tab does. const notifiedRef = useRef | null>(null); useEffect(() => { if (!enabled || !board) return; const dueForReminder = board.today.occurrences.filter( (o) => !o.completed && o.timeDue && o.remindMinutesBefore !== null && isReminderDue(board.today.date, o.timeDue, o.remindMinutesBefore, now) ); if (notifiedRef.current === null) { // First run after mounting (or reconnecting to a fresh board) -- // baseline whatever's already due for a reminder instead of // notifying for old news the Overdue section (or an already-seen // reminder) is already showing. notifiedRef.current = new Set(dueForReminder.map((o) => `${o.scheduledTodoId}-${o.occurrenceDate}`)); return; } for (const o of dueForReminder) { const key = `${o.scheduledTodoId}-${o.occurrenceDate}`; if (notifiedRef.current.has(key)) continue; notifiedRef.current.add(key); const notification = new Notification(o.title, { body: o.timeDue ? `Due at ${formatTimeDue(o.timeDue)}` : undefined, icon: "/icons/icon-192.png", tag: key, // replaces rather than stacking if this somehow runs twice }); notification.onclick = () => { window.focus(); notification.close(); }; } }, [enabled, board, now]); return { // "unsupported" hides the toggle entirely -- nothing to offer. supported: permission !== "unsupported", enabled, permission, requestEnable, disable, }; }