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
This commit is contained in:
Claude 2026-02-20 17:05:40 +00:00
parent c368110b3d
commit c89c1fd973
No known key found for this signature in database
4 changed files with 187 additions and 1 deletions

View file

@ -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 (
<div className="border-t pt-4 mt-4">
<div className="flex items-center justify-between">
<Label htmlFor="notifications-switch" className="text-sm font-semibold">
Notifications
</Label>
<Switch
id="notifications-switch"
checked={enabled}
onCheckedChange={onEnabledChange}
disabled={permissionState === "denied"}
/>
</div>
{permissionState === "denied" && (
<p className="text-xs text-muted-foreground mt-2">
Notifications blocked by browser. Update in browser settings to
enable.
</p>
)}
</div>
);
}

View file

@ -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 */}
<NotificationSettings
enabled={notificationsEnabled}
onEnabledChange={onNotificationsEnabledChange}
isSupported={notificationsSupported}
permissionState={notificationsPermission}
/>
</div>
);
}

View file

@ -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,
};
}

View file

@ -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<typeof loader>();
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={