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>
This commit is contained in:
parent
1584d34b89
commit
401efcd833
5 changed files with 284 additions and 12 deletions
|
|
@ -14,6 +14,7 @@ interface DraftSidebarProps {
|
|||
onCollapsedChange: (collapsed: boolean) => void;
|
||||
queueSection: ReactNode;
|
||||
recentPicksSection: ReactNode;
|
||||
settingsSection?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
|
@ -22,6 +23,7 @@ export function DraftSidebar({
|
|||
onCollapsedChange,
|
||||
queueSection,
|
||||
recentPicksSection,
|
||||
settingsSection,
|
||||
className,
|
||||
}: DraftSidebarProps) {
|
||||
if (collapsed) {
|
||||
|
|
@ -94,6 +96,13 @@ export function DraftSidebar({
|
|||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{/* Settings section (e.g. notification toggle) */}
|
||||
{settingsSection && (
|
||||
<div className="border-t border-border px-4 py-3 flex-shrink-0">
|
||||
{settingsSection}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapse button at bottom */}
|
||||
<div className="border-t border-border p-2 flex-shrink-0">
|
||||
<Button
|
||||
|
|
|
|||
68
app/components/NotificationSettings.tsx
Normal file
68
app/components/NotificationSettings.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { Switch } from "~/components/ui/switch";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group";
|
||||
import type { NotificationMode } from "~/hooks/useDraftNotifications";
|
||||
|
||||
interface NotificationSettingsProps {
|
||||
enabled: boolean;
|
||||
onEnabledChange: (enabled: boolean) => void;
|
||||
mode: NotificationMode;
|
||||
onModeChange: (mode: NotificationMode) => void;
|
||||
permissionState: NotificationPermission | "unsupported";
|
||||
}
|
||||
|
||||
export function NotificationSettings({
|
||||
enabled,
|
||||
onEnabledChange,
|
||||
mode,
|
||||
onModeChange,
|
||||
permissionState,
|
||||
}: NotificationSettingsProps) {
|
||||
if (permissionState === "unsupported") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{enabled && (
|
||||
<RadioGroup
|
||||
value={mode}
|
||||
onValueChange={(value) => onModeChange(value as NotificationMode)}
|
||||
className="space-y-2 mt-3"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="my_turn" id="notif_my_turn" />
|
||||
<Label htmlFor="notif_my_turn" className="text-sm cursor-pointer">
|
||||
My Turn Only
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="all_picks" id="notif_all_picks" />
|
||||
<Label htmlFor="notif_all_picks" className="text-sm cursor-pointer">
|
||||
All Picks
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
app/hooks/useDraftNotifications.ts
Normal file
130
app/hooks/useDraftNotifications.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
18
app/lib/draft-order.ts
Normal file
18
app/lib/draft-order.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* Returns the draft slot for a given pick number in a snake draft.
|
||||
*/
|
||||
export function getTeamForPick<T extends { draftOrder: number }>(
|
||||
pickNumber: number,
|
||||
draftSlots: T[]
|
||||
): T | undefined {
|
||||
const totalTeams = draftSlots.length;
|
||||
if (totalTeams === 0) return undefined;
|
||||
|
||||
const round = Math.ceil(pickNumber / totalTeams);
|
||||
const isEvenRound = round % 2 === 0;
|
||||
let pickInRound = ((pickNumber - 1) % totalTeams) + 1;
|
||||
if (isEvenRound) {
|
||||
pickInRound = totalTeams - pickInRound + 1;
|
||||
}
|
||||
return draftSlots.find((slot) => slot.draftOrder === pickInRound);
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { eq, and, asc, desc, inArray } from "drizzle-orm";
|
|||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { useDraftSocket } from "~/hooks/useDraftSocket";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useEffect, useState, useMemo, useRef } from "react";
|
||||
import { Card } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
|
@ -18,6 +18,9 @@ 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 { getTeamForPick } from "~/lib/draft-order";
|
||||
import { useDraftNotifications } from "~/hooks/useDraftNotifications";
|
||||
import { NotificationSettings } from "~/components/NotificationSettings";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export async function loader(args: any) {
|
||||
|
|
@ -196,8 +199,28 @@ export default function DraftRoom() {
|
|||
autodraftSettings,
|
||||
userAutodraftSettings,
|
||||
isCommissioner,
|
||||
currentUserId,
|
||||
} = useLoaderData<typeof loader>();
|
||||
const { isConnected, connectionError, isReconnecting, on, off } = useDraftSocket(season.id, userTeam?.id);
|
||||
const {
|
||||
permissionState: notificationsPermission,
|
||||
enabled: notificationsEnabled,
|
||||
setEnabled: setNotificationsEnabled,
|
||||
mode: notificationsMode,
|
||||
setMode: setNotificationsMode,
|
||||
sendNotification,
|
||||
} = useDraftNotifications(season.id, currentUserId);
|
||||
|
||||
// Use refs so the socket effect doesn't re-register all handlers when the user
|
||||
// changes notification settings (which would change the sendNotification reference).
|
||||
const sendNotificationRef = useRef(sendNotification);
|
||||
useEffect(() => {
|
||||
sendNotificationRef.current = sendNotification;
|
||||
}, [sendNotification]);
|
||||
const notificationsModeRef = useRef(notificationsMode);
|
||||
useEffect(() => {
|
||||
notificationsModeRef.current = notificationsMode;
|
||||
}, [notificationsMode]);
|
||||
const [picks, setPicks] = useState(draftPicks);
|
||||
const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
|
@ -360,6 +383,30 @@ export default function DraftRoom() {
|
|||
console.log("Pick made:", data);
|
||||
setPicks((prev: any) => [...prev, data.pick]);
|
||||
setCurrentPick(data.nextPickNumber);
|
||||
|
||||
// No meaningful "next turn" notification once the draft is over
|
||||
if (data.isDraftComplete) return;
|
||||
|
||||
const notifTitle = `${season.league.name} Draft`;
|
||||
const nextSlot = getTeamForPick(data.nextPickNumber, draftSlots);
|
||||
const isNowMyTurn = !!(
|
||||
userTeam && nextSlot && nextSlot.team.id === userTeam.id
|
||||
);
|
||||
|
||||
if (isNowMyTurn) {
|
||||
sendNotificationRef.current(notifTitle, `It's your turn to pick!`);
|
||||
} else if (
|
||||
notificationsModeRef.current === "all_picks" &&
|
||||
data.pick?.team?.id !== userTeam?.id
|
||||
) {
|
||||
// Only notify about other teams' picks, not the user's own
|
||||
const pickerName = data.pick?.team?.name || "A team";
|
||||
const participantName = data.pick?.participant?.name || "a participant";
|
||||
sendNotificationRef.current(
|
||||
notifTitle,
|
||||
`${pickerName} picked ${participantName}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimerUpdate = (data: any) => {
|
||||
|
|
@ -447,7 +494,7 @@ export default function DraftRoom() {
|
|||
off("connected-teams-list", handleConnectedTeamsList);
|
||||
off("participant-removed-from-queues", handleParticipantRemovedFromQueues);
|
||||
};
|
||||
}, [on, off, currentPick, userTeam]);
|
||||
}, [on, off, currentPick, userTeam, draftSlots, season.league.name]);
|
||||
|
||||
// Persist sidebar collapsed state
|
||||
useEffect(() => {
|
||||
|
|
@ -657,16 +704,7 @@ export default function DraftRoom() {
|
|||
const currentRound = draftSlots.length > 0 ? Math.ceil(currentPick / draftSlots.length) : 1;
|
||||
|
||||
// Determine whose turn it is
|
||||
const totalTeams = draftSlots.length;
|
||||
const isSnakeDraft = true;
|
||||
const isEvenRound = currentRound % 2 === 0;
|
||||
let pickInRound = ((currentPick - 1) % totalTeams) + 1;
|
||||
if (isSnakeDraft && isEvenRound) {
|
||||
pickInRound = totalTeams - pickInRound + 1;
|
||||
}
|
||||
const currentDraftSlot = draftSlots.find(
|
||||
(slot) => slot.draftOrder === pickInRound
|
||||
);
|
||||
const currentDraftSlot = getTeamForPick(currentPick, draftSlots);
|
||||
const isMyTurn = !!(
|
||||
userTeam && currentDraftSlot && currentDraftSlot.team.id === userTeam.id
|
||||
);
|
||||
|
|
@ -882,6 +920,15 @@ export default function DraftRoom() {
|
|||
recentPicksSection={
|
||||
<SidebarRecentPicks picks={picks} />
|
||||
}
|
||||
settingsSection={
|
||||
<NotificationSettings
|
||||
enabled={notificationsEnabled}
|
||||
onEnabledChange={setNotificationsEnabled}
|
||||
mode={notificationsMode}
|
||||
onModeChange={setNotificationsMode}
|
||||
permissionState={notificationsPermission}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue