Organize/components/scheduled/use-scheduled-notifications.ts

115 lines
4.5 KiB
TypeScript

"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<NotificationPermission | "unsupported">("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<Set<string> | 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,
};
}