brackt/app/hooks/useDraftNotifications.ts
Chris Parsons 401efcd833
Add browser push notifications toggle to draft page (#11)
* Add push notifications implementation plan

Documents the approach for adding a browser Notification API toggle
to the draft page sidebar, including files to create/modify and
edge cases to handle.

https://claude.ai/code/session_0149MvVUYDY6pFUAV1fL4K69

* Add browser push notifications toggle to draft page

Adds a Notifications toggle in the draft sidebar (below Autodraft)
that uses the Browser Notification API to alert users when a pick is
made or when it's their turn, while the tab is not focused.

- New useDraftNotifications hook for state, permission, and localStorage
- New NotificationSettings component with Switch toggle
- Fires "It's your turn to pick!" when the next pick is the user's
- Fires "{Team} picked {Player}" for all other picks
- Gracefully hides toggle when Notification API is unavailable

https://claude.ai/code/session_0149MvVUYDY6pFUAV1fL4K69

* Fix all code review issues with push notifications

- Extract snake draft order calculation to shared getTeamForPick()
  helper in lib/draft-order.ts, removing the duplicated logic
- Fix notification firing on user's own picks (guard with team id check)
- Fix notification firing after draft is complete (early return)
- Use a ref for sendNotification to prevent full socket handler
  re-registration every time the toggle is changed
- Add permission drift listener via Permissions API so the UI reacts
  if the user revokes notification permission in browser settings
- Add SSR guard for document.hidden in sendNotification
- Remove redundant isSupported prop from NotificationSettings;
  derive unsupported state from permissionState === "unsupported"
- Move NotificationSettings out of QueueSection prop-drilling path;
  render it via a new settingsSection slot on DraftSidebar instead

https://claude.ai/code/session_0149MvVUYDY6pFUAV1fL4K69

* Fix second round of notification code review issues

- Add My Turn Only / All Picks mode granularity so users can choose
  to only be notified when it's their pick, or for every pick
- Remove double top-border by stripping leftover border-t/pt-4/mt-4
  wrapper from NotificationSettings (now provided by DraftSidebar)
- Remove unused isSupported from hook return value
- Add n.onclick = () => window.focus() so clicking a notification
  brings the draft tab back into focus
- Scope localStorage keys to userId to avoid shared-browser conflicts
  (keys now: draftNotifications-{userId}-{seasonId})
- Add notificationsModeRef alongside sendNotificationRef so mode
  changes don't trigger full socket handler re-registration

https://claude.ai/code/session_0149MvVUYDY6pFUAV1fL4K69

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 19:30:53 -08:00

130 lines
3.7 KiB
TypeScript

import { useState, useEffect, useCallback } from "react";
export type NotificationMode = "my_turn" | "all_picks";
function getEnabledKey(userId: string, seasonId: string) {
return `draftNotifications-${userId}-${seasonId}`;
}
function getModeKey(userId: string, seasonId: string) {
return `draftNotificationMode-${userId}-${seasonId}`;
}
export function useDraftNotifications(seasonId: string, userId: string) {
const [permissionState, setPermissionState] = useState<
NotificationPermission | "unsupported"
>("unsupported");
const [enabled, setEnabledState] = useState(false);
const [mode, setModeState] = useState<NotificationMode>("my_turn");
// Check browser support, restore persisted preferences, and watch for permission changes
useEffect(() => {
if (typeof window === "undefined" || !("Notification" in window)) {
return;
}
setPermissionState(Notification.permission);
// Restore preferences from localStorage only if permission is granted
if (Notification.permission === "granted") {
const storedEnabled = localStorage.getItem(getEnabledKey(userId, seasonId));
if (storedEnabled === "true") {
setEnabledState(true);
}
}
const storedMode = localStorage.getItem(getModeKey(userId, seasonId));
if (storedMode === "my_turn" || storedMode === "all_picks") {
setModeState(storedMode);
}
// Watch for the user revoking/granting permission in browser settings
let permissionStatus: PermissionStatus | null = null;
navigator.permissions
.query({ name: "notifications" })
.then((status) => {
permissionStatus = status;
status.onchange = () => {
setPermissionState(status.state as NotificationPermission);
// If permission was revoked, disable notifications
if (status.state !== "granted") {
setEnabledState(false);
}
};
})
.catch(() => {
// Permissions API not available in all environments; silently ignore
});
return () => {
if (permissionStatus) {
permissionStatus.onchange = null;
}
};
}, [seasonId, userId]);
const setEnabled = useCallback(
async (value: boolean) => {
if (typeof window === "undefined" || !("Notification" in window)) return;
if (value) {
// Request permission if not yet granted
if (Notification.permission === "default") {
const result = await Notification.requestPermission();
setPermissionState(result);
if (result !== "granted") {
return;
}
} else if (Notification.permission === "denied") {
return;
}
setEnabledState(true);
localStorage.setItem(getEnabledKey(userId, seasonId), "true");
} else {
setEnabledState(false);
localStorage.setItem(getEnabledKey(userId, seasonId), "false");
}
},
[userId, seasonId]
);
const setMode = useCallback(
(value: NotificationMode) => {
setModeState(value);
localStorage.setItem(getModeKey(userId, seasonId), value);
},
[userId, seasonId]
);
const sendNotification = useCallback(
(title: string, body: string) => {
if (
!enabled ||
typeof window === "undefined" ||
!("Notification" in window) ||
Notification.permission !== "granted" ||
typeof document === "undefined" ||
!document.hidden
) {
return;
}
const n = new Notification(title, {
body,
tag: `draft-${seasonId}`,
});
n.onclick = () => window.focus();
},
[enabled, seasonId]
);
return {
permissionState,
enabled,
setEnabled,
mode,
setMode,
sendNotification,
};
}