115 lines
4.5 KiB
TypeScript
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,
|
|
};
|
|
}
|