From c89c1fd973a93fb52b3099d5a7dce366d0a7bf7e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Feb 2026 17:05:40 +0000 Subject: [PATCH] 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 --- app/components/NotificationSettings.tsx | 43 ++++++++++ app/components/draft/QueueSection.tsx | 17 ++++ app/hooks/useDraftNotifications.ts | 84 +++++++++++++++++++ .../leagues/$leagueId.draft.$seasonId.tsx | 44 +++++++++- 4 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 app/components/NotificationSettings.tsx create mode 100644 app/hooks/useDraftNotifications.ts diff --git a/app/components/NotificationSettings.tsx b/app/components/NotificationSettings.tsx new file mode 100644 index 0000000..4fd90c1 --- /dev/null +++ b/app/components/NotificationSettings.tsx @@ -0,0 +1,43 @@ +import { Switch } from "~/components/ui/switch"; +import { Label } from "~/components/ui/label"; + +interface NotificationSettingsProps { + enabled: boolean; + onEnabledChange: (enabled: boolean) => void; + isSupported: boolean; + permissionState: NotificationPermission | "unsupported"; +} + +export function NotificationSettings({ + enabled, + onEnabledChange, + isSupported, + permissionState, +}: NotificationSettingsProps) { + if (!isSupported) { + return null; + } + + return ( +
+
+ + +
+ + {permissionState === "denied" && ( +

+ Notifications blocked by browser. Update in browser settings to + enable. +

+ )} +
+ ); +} diff --git a/app/components/draft/QueueSection.tsx b/app/components/draft/QueueSection.tsx index a548575..ba6b992 100644 --- a/app/components/draft/QueueSection.tsx +++ b/app/components/draft/QueueSection.tsx @@ -1,6 +1,7 @@ import { Button } from "~/components/ui/button"; import { Badge } from "~/components/ui/badge"; import { AutodraftSettings } from "~/components/AutodraftSettings"; +import { NotificationSettings } from "~/components/NotificationSettings"; import { DndContext, closestCenter, @@ -48,6 +49,10 @@ interface QueueSectionProps { onRemoveFromQueue: (queueId: string) => void; onAutodraftUpdate: (isEnabled: boolean, mode: "next_pick" | "while_on") => void; onReorder: (participantIds: string[]) => void; + notificationsEnabled: boolean; + onNotificationsEnabledChange: (enabled: boolean) => void; + notificationsSupported: boolean; + notificationsPermission: NotificationPermission | "unsupported"; } // Sortable queue item component @@ -130,6 +135,10 @@ export function QueueSection({ onRemoveFromQueue, onAutodraftUpdate, onReorder, + notificationsEnabled, + onNotificationsEnabledChange, + notificationsSupported, + notificationsPermission, }: QueueSectionProps) { const sensors = useSensors( useSensor(PointerSensor), @@ -196,6 +205,14 @@ export function QueueSection({ isMyTurn={isMyTurn} onUpdate={onAutodraftUpdate} /> + + {/* Notification Settings */} + ); } diff --git a/app/hooks/useDraftNotifications.ts b/app/hooks/useDraftNotifications.ts new file mode 100644 index 0000000..51f9209 --- /dev/null +++ b/app/hooks/useDraftNotifications.ts @@ -0,0 +1,84 @@ +import { useState, useEffect, useCallback } from "react"; + +function getStorageKey(seasonId: string) { + return `draftNotifications-${seasonId}`; +} + +export function useDraftNotifications(seasonId: string) { + const [isSupported, setIsSupported] = useState(false); + const [permissionState, setPermissionState] = useState< + NotificationPermission | "unsupported" + >("unsupported"); + const [enabled, setEnabledState] = useState(false); + + // Check browser support and restore persisted preference + useEffect(() => { + if (typeof window === "undefined" || !("Notification" in window)) { + return; + } + + setIsSupported(true); + setPermissionState(Notification.permission); + + // Restore preference from localStorage only if permission is granted + if (Notification.permission === "granted") { + const stored = localStorage.getItem(getStorageKey(seasonId)); + if (stored === "true") { + setEnabledState(true); + } + } + }, [seasonId]); + + const setEnabled = useCallback( + async (value: boolean) => { + if (!isSupported) 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(getStorageKey(seasonId), "true"); + } else { + setEnabledState(false); + localStorage.setItem(getStorageKey(seasonId), "false"); + } + }, + [isSupported, seasonId] + ); + + const sendNotification = useCallback( + (title: string, body: string) => { + if ( + !enabled || + !isSupported || + Notification.permission !== "granted" || + !document.hidden + ) { + return; + } + + new Notification(title, { + body, + tag: `draft-${seasonId}`, + }); + }, + [enabled, isSupported, seasonId] + ); + + return { + isSupported, + permissionState, + enabled, + setEnabled, + sendNotification, + }; +} diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index a06917f..729a913 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -18,6 +18,7 @@ import { SidebarRecentPicks } from "~/components/draft/SidebarRecentPicks"; import { DraftGridSection } from "~/components/draft/DraftGridSection"; import { ConnectionOverlay } from "~/components/draft/ConnectionOverlay"; import { calculateDraftEligibility } from "~/lib/draft-eligibility"; +import { useDraftNotifications } from "~/hooks/useDraftNotifications"; import { toast } from "sonner"; export async function loader(args: any) { @@ -198,6 +199,13 @@ export default function DraftRoom() { isCommissioner, } = useLoaderData(); const { isConnected, connectionError, isReconnecting, on, off } = useDraftSocket(season.id, userTeam?.id); + const { + isSupported: notificationsSupported, + permissionState: notificationsPermission, + enabled: notificationsEnabled, + setEnabled: setNotificationsEnabled, + sendNotification, + } = useDraftNotifications(season.id); const [picks, setPicks] = useState(draftPicks); const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1); const [searchQuery, setSearchQuery] = useState(""); @@ -360,6 +368,36 @@ export default function DraftRoom() { console.log("Pick made:", data); setPicks((prev: any) => [...prev, data.pick]); setCurrentPick(data.nextPickNumber); + + // Send browser notification + const pickerName = data.pick?.team?.name || "A team"; + const participantName = data.pick?.participant?.name || "a participant"; + const notifTitle = `${season.league.name} Draft`; + + // Check if the next pick is the current user's turn + const nextPick = data.nextPickNumber; + const totalTeams = draftSlots.length; + const nextRound = Math.ceil(nextPick / totalTeams); + const isEven = nextRound % 2 === 0; + let nextPickInRound = ((nextPick - 1) % totalTeams) + 1; + if (isEven) { + nextPickInRound = totalTeams - nextPickInRound + 1; + } + const nextSlot = draftSlots.find( + (slot) => slot.draftOrder === nextPickInRound + ); + const isNowMyTurn = !!( + userTeam && nextSlot && nextSlot.team.id === userTeam.id + ); + + if (isNowMyTurn) { + sendNotification(notifTitle, `It's your turn to pick!`); + } else { + sendNotification( + notifTitle, + `${pickerName} picked ${participantName}` + ); + } }; const handleTimerUpdate = (data: any) => { @@ -447,7 +485,7 @@ export default function DraftRoom() { off("connected-teams-list", handleConnectedTeamsList); off("participant-removed-from-queues", handleParticipantRemovedFromQueues); }; - }, [on, off, currentPick, userTeam]); + }, [on, off, currentPick, userTeam, sendNotification, draftSlots, season.league.name]); // Persist sidebar collapsed state useEffect(() => { @@ -877,6 +915,10 @@ export default function DraftRoom() { setUserAutodraft({ isEnabled, mode }); }} onReorder={handleReorderQueue} + notificationsEnabled={notificationsEnabled} + onNotificationsEnabledChange={setNotificationsEnabled} + notificationsSupported={notificationsSupported} + notificationsPermission={notificationsPermission} /> } recentPicksSection={