import { useLoaderData, Link, useRevalidator } from "react-router"; import { useAuth } from "@clerk/react-router"; 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 { logger } from "~/lib/logger"; import { useCallback, useEffect, useState, useMemo, useRef } from "react"; import { Button } from "~/components/ui/button"; import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog"; import { getAuth } from "@clerk/react-router/server"; import { getTeamQueue } from "~/models/draft-queue"; import { buildOwnerMap } from "~/lib/owner-map"; import { DraftSidebar } from "~/components/DraftSidebar"; import { TeamRosterView } from "~/components/draft/TeamRosterView"; import { DraftSummaryView } from "~/components/draft/DraftSummaryView"; import { QueueSection } from "~/components/draft/QueueSection"; import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection"; import { SidebarRecentPicks } from "~/components/draft/SidebarRecentPicks"; import { RecentPicksFeed } from "~/components/draft/RecentPicksFeed"; import { DraftGridSection } from "~/components/draft/DraftGridSection"; import { ConnectionOverlay } from "~/components/draft/ConnectionOverlay"; import { AuthRecoveryOverlay } from "~/components/draft/AuthRecoveryOverlay"; import { ParticipantSelectionDialog } from "~/components/draft/ParticipantSelectionDialog"; import { calculateDraftEligibility } from "~/lib/draft-eligibility"; import { getTeamForPick } from "~/lib/draft-order"; import { useDraftNotifications } from "~/hooks/useDraftNotifications"; import { useMediaQuery } from "~/hooks/useMediaQuery"; import { NotificationSettings } from "~/components/NotificationSettings"; import { AutodraftSettings, getAutodraftLabel, AutodraftBadgeWithPopover } from "~/components/AutodraftSettings"; import { toast } from "sonner"; import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer"; import { Users, LayoutGrid, ListChecks, Settings, ListOrdered } from "lucide-react"; import type { Route } from "./+types/$leagueId.draft.$seasonId"; export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { return [{ title: `Draft — ${data?.season?.league?.name ?? "League"} - Brackt` }]; } type QueueItem = typeof schema.draftQueue.$inferSelect; type AutodraftStatusEntry = { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean; }; const MOBILE_TABS_BASE = [ { id: "available" as const, label: "Participants", Icon: Users }, { id: "board" as const, label: "Board", Icon: LayoutGrid }, { id: "teams" as const, label: "Teams", Icon: ListChecks }, { id: "controls" as const, label: "Controls", Icon: Settings }, ]; const QUEUE_TAB = { id: "queue" as const, label: "Queue", Icon: ListOrdered }; export async function loader(args: Route.LoaderArgs) { const { params } = args; const { seasonId, leagueId } = params; const { userId } = await getAuth(args); if (!seasonId) { throw new Response("Season ID is required", { status: 400 }); } const db = database(); // Get season details const season = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId), with: { league: true, teams: true, }, }); if (!season) { throw new Response("Season not found", { status: 404 }); } // Resolve user's team and commissioner status (null userId means neither) const userTeam = userId ? season.teams.find((team) => team.ownerId === userId) : undefined; const isCommissioner = userId ? await db.query.commissioners.findFirst({ where: and( eq(schema.commissioners.leagueId, leagueId), eq(schema.commissioners.userId, userId) ), }) : null; // Access control: if draft board is not public, require authentication and membership if (!season.league.isPublicDraftBoard) { if (!userId) { throw new Response("You must be logged in to view the draft room", { status: 401, }); } if (!userTeam && !isCommissioner) { throw new Response("You do not have access to this draft room", { status: 403, }); } } // Get draft slots (draft order) - using select instead of query builder const draftSlots = await db .select({ id: schema.draftSlots.id, draftOrder: schema.draftSlots.draftOrder, team: schema.teams, }) .from(schema.draftSlots) .innerJoin(schema.teams, eq(schema.draftSlots.teamId, schema.teams.id)) .where(eq(schema.draftSlots.seasonId, seasonId)) .orderBy(asc(schema.draftSlots.draftOrder)); // Get all draft picks - using select instead of query builder const draftPicks = await db .select({ id: schema.draftPicks.id, pickNumber: schema.draftPicks.pickNumber, round: schema.draftPicks.round, pickInRound: schema.draftPicks.pickInRound, timeUsed: schema.draftPicks.timeUsed, team: schema.teams, participant: schema.participants, sport: schema.sports, }) .from(schema.draftPicks) .innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id)) .innerJoin( schema.participants, eq(schema.draftPicks.participantId, schema.participants.id) ) .innerJoin( schema.sportsSeasons, eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id) ) .innerJoin( schema.sports, eq(schema.sportsSeasons.sportId, schema.sports.id) ) .where(eq(schema.draftPicks.seasonId, seasonId)) .orderBy(asc(schema.draftPicks.pickNumber)); // Get sports seasons for this season const seasonSports = await db.query.seasonSports.findMany({ where: eq(schema.seasonSports.seasonId, seasonId), }); const sportsSeasonIds = seasonSports.map((ss) => ss.sportsSeasonId); // Get ALL participants (both drafted and undrafted) - sorted by EV desc, then name // Filtering will be done on the client side based on the "Show Drafted" toggle const availableParticipants = sportsSeasonIds.length > 0 ? await db .select({ id: schema.participants.id, name: schema.participants.name, vorpValue: schema.participants.vorpValue, sport: schema.sports, }) .from(schema.participants) .innerJoin( schema.sportsSeasons, eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id) ) .innerJoin( schema.sports, eq(schema.sportsSeasons.sportId, schema.sports.id) ) .where( sportsSeasonIds.length === 1 ? eq(schema.participants.sportsSeasonId, sportsSeasonIds[0]) : inArray(schema.participants.sportsSeasonId, sportsSeasonIds) ) .orderBy( desc(schema.participants.vorpValue), asc(schema.participants.name) ) : []; // Load user's team queue if they have a team const userQueue = userTeam ? await getTeamQueue(userTeam.id).catch((err) => { logger.error("[DraftLoader] Failed to load queue, defaulting to empty:", err); return []; }) : []; // Load draft timers for all teams const timers = await db.query.draftTimers.findMany({ where: eq(schema.draftTimers.seasonId, seasonId), }); // Load autodraft settings for all teams const autodraftSettings = await db.query.autodraftSettings.findMany({ where: eq(schema.autodraftSettings.seasonId, seasonId), }); // Derive user's autodraft settings from the already-loaded list const userAutodraftSettings = userTeam ? (autodraftSettings.find((s) => s.teamId === userTeam.id) ?? null) : null; const ownerMap = await buildOwnerMap(draftSlots); return { season, draftSlots, draftPicks, availableParticipants, userTeam, userQueue, timers, autodraftSettings, userAutodraftSettings, isCommissioner: !!isCommissioner, currentUserId: userId, ownerMap, }; } export default function DraftRoom() { const { season, draftSlots, draftPicks, availableParticipants, userTeam, userQueue, timers, autodraftSettings, userAutodraftSettings, isCommissioner, currentUserId, ownerMap, } = useLoaderData(); const { revalidate, state: revalidatorState } = useRevalidator(); const { userId: clerkUserId, getToken } = useAuth(); const { isConnected, connectionError, isReconnecting, reconnectCount, socketVersion, 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]); // Track current autodraft state for detecting server-initiated auto-disable const [userAutodraft, setUserAutodraft] = useState({ isEnabled: userAutodraftSettings?.isEnabled || false, mode: (userAutodraftSettings?.mode || "next_pick") as "next_pick" | "while_on", queueOnly: userAutodraftSettings?.queueOnly || false, }); const userAutodraftRef = useRef(userAutodraft); useEffect(() => { userAutodraftRef.current = userAutodraft; }, [userAutodraft]); // Re-fetch loader data after each reconnect so stale picks/timers are refreshed. // Schedule a delayed retry so that if the first attempt fails (common on mobile // where the network may not be fully restored yet), we get another chance. const revalidationRetryRef = useRef | null>(null); useEffect(() => { if (reconnectCount > 0) { revalidate(); // Clear any pending retry from a previous reconnect if (revalidationRetryRef.current) { clearTimeout(revalidationRetryRef.current); } revalidationRetryRef.current = setTimeout(() => { revalidate(); revalidationRetryRef.current = null; }, 3000); } return () => { if (revalidationRetryRef.current) { clearTimeout(revalidationRetryRef.current); } }; }, [reconnectCount, revalidate]); // Guard against Clerk JWT expiry race: if the loader ran without a userId // (expired short-lived JWT) but the Clerk client SDK has a valid session, // revalidate so the loader re-runs with a fresh token and returns correct // userTeam/userQueue data. This can happen repeatedly on mobile when the // user backgrounds and foregrounds the app, so we don't limit it to a // single attempt. Instead we debounce to avoid rapid-fire revalidations. // // Cap at 3 attempts to avoid an infinite loop if the server consistently // fails to resolve a userId (e.g. clock skew, misconfigured Clerk keys). // After 3 failures we fall through to the authDegraded overlay. const authRecoveryTimerRef = useRef | undefined>(undefined); const authRecoveryAttemptsRef = useRef(0); const MAX_AUTH_RECOVERY_ATTEMPTS = 3; useEffect(() => { if (clerkUserId && !currentUserId) { if (authRecoveryAttemptsRef.current >= MAX_AUTH_RECOVERY_ATTEMPTS) { setAuthDegraded(true); return; } // Small debounce so we don't fire multiple times in quick succession // (e.g. visibility change + socket reconnect both updating deps). clearTimeout(authRecoveryTimerRef.current); authRecoveryTimerRef.current = setTimeout(() => { authRecoveryAttemptsRef.current += 1; revalidate(); }, 100); } else if (currentUserId) { // Auth recovered — reset the counter for the next background cycle. authRecoveryAttemptsRef.current = 0; } return () => clearTimeout(authRecoveryTimerRef.current); }, [clerkUserId, currentUserId, revalidate]); // Auth session protection: proactively refresh the Clerk JWT to prevent // silent logout during long-running draft sessions. const [authDegraded, setAuthDegraded] = useState(false); // Fetch wrapper that detects 401 responses and shows the auth recovery // overlay instead of letting each handler deal with it individually. // Returns null on 401 so callers can bail with `if (!response) return;`. const authFetch = useCallback(async (url: string, init?: RequestInit): Promise => { const response = await fetch(url, init); if (response.status === 401) { setAuthDegraded(true); return null; } return response; }, []); // Refresh the Clerk JWT when the tab becomes visible again. Browsers // throttle/suspend intervals in background tabs, so the SDK's automatic // 60-second refresh may not have run. Getting a fresh token here prevents // the next user action from hitting a 401. After a successful refresh we // also revalidate the loader so it re-runs with the fresh token — without // this, userTeam/userQueue stay stale from the previous (possibly // unauthenticated) loader run. useEffect(() => { // Nothing to do once auth is fully degraded — avoid re-registering a // listener that would immediately exit on every future visibility change. if (authDegraded) return; let aborted = false; let inFlight = false; const handleVisibilityChange = async () => { // Ignore hide events, and skip if another invocation is already running // (rapid tab switching) or this effect has been cleaned up. if (document.visibilityState !== "visible" || !clerkUserId || inFlight) return; inFlight = true; try { const token = await getToken({ skipCache: true }); if (aborted) return; if (token !== null) { // Token refreshed successfully. Only revalidate if the loader is // missing auth (currentUserId is null) — if the loader already has // a valid user there's no stale state to fix and the extra round // trip just adds unnecessary server load. if (!currentUserId) { revalidate(); } } else { // Retry after a longer delay — mobile networks may take a few // seconds to fully wake up after background suspension. await new Promise((r) => setTimeout(r, 2500)); if (aborted) return; const retryToken = await getToken({ skipCache: true }); if (aborted) return; if (retryToken !== null) { revalidate(); } else { setAuthDegraded(true); } } } catch { try { await new Promise((r) => setTimeout(r, 2500)); if (aborted) return; const retryToken = await getToken({ skipCache: true }); if (aborted) return; if (retryToken !== null) { revalidate(); } else { setAuthDegraded(true); } } catch { if (!aborted) setAuthDegraded(true); } } finally { inFlight = false; } }; document.addEventListener("visibilitychange", handleVisibilityChange); return () => { aborted = true; document.removeEventListener("visibilitychange", handleVisibilityChange); }; }, [clerkUserId, currentUserId, getToken, authDegraded, revalidate]); // Track revalidation lifecycle to sync local state from fresh loader data. // // The problem: `picks` and `currentPick` are local state initialized once // from useLoaderData(). When revalidate() runs after a reconnect, React // Router re-fetches `draftPicks`/`season` — but those updates never flow // back into the local useState copies automatically. // // Race condition: a `pick-made` socket event that arrives *during* the // revalidation window would normally be applied to the stale prev state and // then overwritten when we sync from the DB snapshot. We prevent that by // buffering live picks while revalidation is in flight and merging them // (deduplicated by ID) after the DB snapshot lands. const revalidatorStateRef = useRef(revalidatorState); const isRevalidatingRef = useRef(false); const pendingPicksDuringRevalidationRef = useRef<(typeof draftPicks)[number][]>([]); // Track the draftPicks reference when revalidation starts. If it hasn't // changed by the time revalidation completes, the loader didn't return new // data (e.g. network failure) and we should NOT overwrite state that may // have been freshly set by draft-state-sync. const draftPicksAtRevalidationStartRef = useRef(draftPicks); // Same pattern for userQueue: only sync from loader if the reference changed, // meaning the fetch actually returned fresh data. Prevents overwriting // socket-applied queue changes (queue-updated, participant-removed-from-queues) // with stale loader data when a revalidation fetch fails. const userQueueAtRevalidationStartRef = useRef(userQueue); // Tracks how many queue mutations are in-flight from this window. While > 0, // incoming queue-updated socket events are ignored so that the server echo // doesn't clobber a more-recent optimistic update (e.g. rapid drag-reorders). // The initiating window uses the HTTP response as the authoritative state; // other windows use the socket event. const pendingQueueMutationsRef = useRef(0); useEffect(() => { const prev = revalidatorStateRef.current; revalidatorStateRef.current = revalidatorState; if (prev !== "loading" && revalidatorState === "loading") { // Revalidation just started — buffer any live picks to avoid loss isRevalidatingRef.current = true; pendingPicksDuringRevalidationRef.current = []; draftPicksAtRevalidationStartRef.current = draftPicks; userQueueAtRevalidationStartRef.current = userQueue; } else if (prev === "loading" && revalidatorState === "idle") { // Revalidation just completed — merge DB snapshot with buffered picks isRevalidatingRef.current = false; // If draftPicks is the same reference as when revalidation started, the // loader didn't actually update (likely a failed fetch). Skip the sync // to avoid overwriting fresh draft-state-sync data with stale state. if (draftPicks === draftPicksAtRevalidationStartRef.current) { pendingPicksDuringRevalidationRef.current = []; return; } const dbPickIds = new Set(draftPicks.map((p) => p.id)); const missedPicks = pendingPicksDuringRevalidationRef.current.filter( (p) => !dbPickIds.has(p.id) ); pendingPicksDuringRevalidationRef.current = []; setPicks([...draftPicks, ...missedPicks]); setCurrentPick(season.currentPickNumber || 1); setIsPaused(season.draftPaused || false); setIsDraftComplete( season.status === "active" || season.status === "completed" ); // Sync the queue if the loader returned fresh data for it. Using reference // equality (same pattern as draftPicks above): drizzle always returns a new // array on a successful fetch, so a changed reference means the fetch // succeeded and we can trust the value. A same reference means the fetch // failed and we must NOT overwrite socket-applied changes (queue-updated, // participant-removed-from-queues) with stale data. // // Guard: skip if the loader ran unauthenticated (currentUserId null) to avoid // overwriting with an empty userQueue returned for an expired JWT session. if (currentUserId && userQueue !== userQueueAtRevalidationStartRef.current) { setQueue(userQueue); } // Sync timers — the server-side timer state may have changed while // we were disconnected (time bank adjustments, new timers, etc.) if (timers.length > 0) { setTeamTimers((currentTimers) => { const updated = { ...currentTimers }; timers.forEach((timer) => { updated[timer.teamId] = timer.timeRemaining; }); return updated; }); } // Sync autodraft settings — teams may have toggled autodraft while // we were away. setAutodraftStatus(() => { const status: Record = {}; autodraftSettings.forEach((setting) => { status[setting.teamId] = { isEnabled: setting.isEnabled, mode: setting.mode, queueOnly: setting.queueOnly, }; }); return status; }); } }, [revalidatorState, draftPicks, season, userQueue, timers, autodraftSettings, currentUserId]); const [picks, setPicks] = useState(draftPicks); const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1); const [searchQuery, setSearchQuery] = useState(""); const [hideDrafted, setHideDrafted] = useState(true); const [hideIneligible, setHideIneligible] = useState(true); const [hideCompletedSports, setHideCompletedSports] = useState(false); const [sportFilters, setSportFilters] = useState([]); const [queue, setQueue] = useState(userQueue); const [isPaused, setIsPaused] = useState(season.draftPaused || false); const [isDraftComplete, setIsDraftComplete] = useState( season.status === "active" || season.status === "completed" ); // Sidebar and tabs state const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { if (typeof window === "undefined") return false; const stored = localStorage.getItem("draftSidebarCollapsed"); return stored ? JSON.parse(stored) : false; }); const [activeTab, setActiveTab] = useState<"participants" | "board" | "rosters" | "summary">("participants"); const [mobileTab, setMobileTab] = useState<"available" | "queue" | "board" | "teams" | "controls">( !userTeam && isCommissioner ? "board" : "available" ); const [teamsSubTab, setTeamsSubTab] = useState<"rosters" | "summary">("rosters"); const [boardSubTab, setBoardSubTab] = useState<"board" | "pick-list">("board"); const isMobile = useMediaQuery("(max-width: 767px)"); // Track autodraft status for all teams const [autodraftStatus, setAutodraftStatus] = useState>(() => { const status: Record = {}; autodraftSettings.forEach((setting) => { status[setting.teamId] = { isEnabled: setting.isEnabled, mode: setting.mode, queueOnly: setting.queueOnly, }; }); return status; }); // Track connected teams const [connectedTeams, setConnectedTeams] = useState>(new Set()); const [teamTimers, setTeamTimers] = useState>(() => { // Initialize with timers from loader data, or use initial time if draft hasn't started const timersMap: Record = {}; const initialTime = season.draftInitialTime || 120; if (timers.length > 0) { // Draft has started, use actual timers timers.forEach((timer) => { timersMap[timer.teamId] = timer.timeRemaining; }); } else { // Draft hasn't started, show initial time for all teams draftSlots.forEach((slot) => { timersMap[slot.team.id] = initialTime; }); } return timersMap; }); const [forcePickDialogOpen, setForcePickDialogOpen] = useState(false); const [selectedPickSlot, setSelectedPickSlot] = useState<{ pickNumber: number; teamId: string; } | null>(null); // Replace pick dialog state const [replacePickDialogOpen, setReplacePickDialogOpen] = useState(false); const [replacePickSlot, setReplacePickSlot] = useState<{ pickNumber: number; teamId: string; oldParticipantId: string; } | null>(null); // Rollback confirm dialog state const [rollbackConfirmOpen, setRollbackConfirmOpen] = useState(false); const [rollbackPickNumber, setRollbackPickNumber] = useState(null); const [isRollingBack, setIsRollingBack] = useState(false); // Time bank adjustment dialog state const [timeBankDialogOpen, setTimeBankDialogOpen] = useState(false); const [timeBankTeamId, setTimeBankTeamId] = useState(null); const [commissionerAutodraftDialogOpen, setCommissionerAutodraftDialogOpen] = useState(false); const [commissionerAutodraftTeamId, setCommissionerAutodraftTeamId] = useState(null); const [timeBankAmount, setTimeBankAmount] = useState("1"); const [timeBankUnit, setTimeBankUnit] = useState<"seconds" | "minutes" | "hours">("minutes"); const [timeBankDirection, setTimeBankDirection] = useState<"add" | "remove">("add"); const [isAdjustingTimeBank, setIsAdjustingTimeBank] = useState(false); // Shared transforms for eligibility calculations const transformedPicks = useMemo( () => picks.map((p) => ({ teamId: p.team.id, participant: { id: p.participant.id, sport: { id: p.sport.id, name: p.sport.name }, }, })), [picks] ); const transformedParticipants = useMemo( () => availableParticipants.map((p) => ({ id: p.id, sport: { id: p.sport.id, name: p.sport.name }, })), [availableParticipants] ); const seasonSportsData = useMemo(() => { const sportsMap = new Map(); availableParticipants.forEach((p) => { if (!sportsMap.has(p.sport.id)) { sportsMap.set(p.sport.id, { id: p.sport.id, name: p.sport.name }); } }); return Array.from(sportsMap.values()).toSorted((a, b) => a.name.localeCompare(b.name) ); }, [availableParticipants]); const participantRanks = useMemo(() => { const ranks = new Map(); const sportCounters = new Map(); availableParticipants.forEach((p, i) => { const sportIdx = (sportCounters.get(p.sport.id) ?? 0) + 1; sportCounters.set(p.sport.id, sportIdx); ranks.set(p.id, { overallRank: i + 1, sportRank: sportIdx }); }); return ranks; }, [availableParticipants]); // Calculate draft eligibility for current user's team const eligibility = useMemo(() => { if (!userTeam) return null; const userTeamPicks = transformedPicks.filter( (p) => p.teamId === userTeam.id ); return calculateDraftEligibility( userTeam.id, userTeamPicks, transformedPicks, transformedParticipants, seasonSportsData, season.draftRounds, season.teams ); }, [userTeam, transformedPicks, transformedParticipants, seasonSportsData, season]); // Calculate eligibility for force manual pick dialog (selected team) const forcePickEligibility = useMemo(() => { if (!selectedPickSlot) return null; const selectedTeamPicks = transformedPicks.filter( (p) => p.teamId === selectedPickSlot.teamId ); return calculateDraftEligibility( selectedPickSlot.teamId, selectedTeamPicks, transformedPicks, transformedParticipants, seasonSportsData, season.draftRounds, season.teams ); }, [selectedPickSlot, transformedPicks, transformedParticipants, seasonSportsData, season]); // Calculate eligibility for replace pick dialog — exclude the old pick so its slot is free const replacePickEligibility = useMemo(() => { if (!replacePickSlot) return null; const allPicksExcluding = transformedPicks.filter( (p) => p.participant.id !== replacePickSlot.oldParticipantId ); const selectedTeamPicks = allPicksExcluding.filter( (p) => p.teamId === replacePickSlot.teamId ); return calculateDraftEligibility( replacePickSlot.teamId, selectedTeamPicks, allPicksExcluding, transformedParticipants, seasonSportsData, season.draftRounds, season.teams ); }, [replacePickSlot, transformedPicks, transformedParticipants, seasonSportsData, season]); // Listen for new picks from other users useEffect(() => { type PickMadePayload = { pick: (typeof draftPicks)[number] & { team?: { id?: string; name?: string }; participant?: { name?: string } }; nextPickNumber: number; isDraftComplete?: boolean }; const handlePickMade = (data: PickMadePayload) => { if (isRevalidatingRef.current) { // Buffer this pick; the revalidation-complete handler will merge it // into the DB snapshot so it isn't silently dropped. // Don't update currentPick either — the sync effect will set it from // fresh season.currentPickNumber once loading → idle, keeping the // pick counter and the picks list in sync with each other. pendingPicksDuringRevalidationRef.current.push(data.pick); } else { setPicks((prev) => [...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: { teamId: string; timeRemaining: number; currentPickNumber: number | null }) => { // Bail out without creating a new object reference if nothing changed. // Without this guard the spread `{ ...prev }` always returns a new object, // causing a full re-render of the DraftRoom tree on every 1-second tick. setTeamTimers((prev) => { if (prev[data.teamId] === data.timeRemaining) return prev; return { ...prev, [data.teamId]: data.timeRemaining }; }); // Also sync the current pick number from the server. This is critical // for mobile reconnection: timer-update events arrive every second, so // this ensures currentPick is correct within 1s of reconnecting — even // if the HTTP revalidation hasn't completed yet. // React's useState already bails out when the new value equals the old // one (Object.is comparison on primitives), so no manual guard needed. if (data.currentPickNumber !== null) { setCurrentPick(data.currentPickNumber); } }; const handleDraftPaused = () => { setIsPaused(true); }; const handleDraftResumed = () => { setIsPaused(false); }; const handleDraftCompleted = () => { setIsDraftComplete(true); }; const handleAutodraftUpdated = (data: { teamId: string; isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean; source?: "commissioner" | "user"; }) => { setAutodraftStatus((prev) => ({ ...prev, [data.teamId]: { isEnabled: data.isEnabled, mode: data.mode, queueOnly: data.queueOnly }, })); // Update user's local state if it's their team if (userTeam && data.teamId === userTeam.id) { if (data.source === "commissioner") { if (!data.isEnabled) { toast.info("Commissioner turned off your autodraft"); } else { toast.info(`Commissioner changed your autodraft to "${getAutodraftLabel(data.isEnabled, data.mode, data.queueOnly)}"`); } } else { // Detect server-side auto-disable: was enabled with queueOnly, now disabled const prev = userAutodraftRef.current; if (!data.isEnabled && prev.isEnabled && prev.queueOnly && prev.mode === "while_on") { toast.info("Autodraft disabled — your queue is empty"); } } setUserAutodraft({ isEnabled: data.isEnabled, mode: data.mode, queueOnly: data.queueOnly, }); } }; const handleTeamConnected = (data: { teamId: string }) => { setConnectedTeams((prev) => new Set(prev).add(data.teamId)); }; const handleTeamDisconnected = (data: { teamId: string }) => { setConnectedTeams((prev) => { const newSet = new Set(prev); newSet.delete(data.teamId); return newSet; }); }; const handleConnectedTeamsList = (data: { teamIds: string[] }) => { setConnectedTeams(new Set(data.teamIds)); }; const handleParticipantRemovedFromQueues = (data: { participantId: string }) => { setQueue((prev) => prev.filter((item) => item.participantId !== data.participantId) ); }; const handleQueueEligibilityPruned = (data: { teamId: string; removedParticipantIds: string[] }) => { if (data.teamId !== userTeam?.id) return; const removed = new Set(data.removedParticipantIds); setQueue((prev) => prev.filter((item) => !removed.has(item.participantId))); }; // Fired when another window/session changes the user's queue (add/remove/reorder/clear). // Only received by this team's own sockets (emitted to team-${teamId} private room). // Ignored while this window has in-flight mutations to prevent the server echo // from clobbering a more-recent optimistic update. const handleQueueUpdated = (data: { queue: QueueItem[] }) => { if (pendingQueueMutationsRef.current > 0) return; setQueue(data.queue); }; const handlePickReplaced = (data: { pickNumber: number; pick: (typeof draftPicks)[number] }) => { setPicks((prev) => prev.map((p) => (p.pickNumber === data.pickNumber ? data.pick : p)) ); }; const handleDraftRolledBack = (data: { pickNumber: number }) => { setPicks((prev) => prev.filter((p) => p.pickNumber < data.pickNumber) ); setCurrentPick(data.pickNumber); setIsDraftComplete(false); setIsPaused(false); }; // Full state sync sent by the server on join-draft. Provides an immediate, // socket-based path to bring the client up to date — critical for mobile // reconnection where the HTTP revalidation may be delayed or fail entirely. const handleDraftStateSync = (data: { picks: (typeof draftPicks)[number][]; currentPickNumber: number; isPaused: boolean; status: string; timers?: Array<{ teamId: string; timeRemaining: number }>; queue?: QueueItem[] }) => { // If a revalidation is in-flight, let it complete and handle the merge. // The revalidation path already buffers picks and deduplicates — layering // this on top would risk conflicts. But if NOT revalidating, apply the // server snapshot directly. if (!isRevalidatingRef.current) { setPicks(data.picks); setCurrentPick(data.currentPickNumber); setIsPaused(data.isPaused); setIsDraftComplete( data.status === "active" || data.status === "completed" ); if (data.timers) { setTeamTimers((prev) => { const updated = { ...prev }; data.timers?.forEach((t) => { updated[t.teamId] = t.timeRemaining; }); return updated; }); } if (data.queue) { setQueue(data.queue); } } }; on("pick-made", handlePickMade); on("timer-update", handleTimerUpdate); on("draft-paused", handleDraftPaused); on("draft-resumed", handleDraftResumed); on("draft-completed", handleDraftCompleted); on("autodraft-updated", handleAutodraftUpdated); on("team-connected", handleTeamConnected); on("team-disconnected", handleTeamDisconnected); on("connected-teams-list", handleConnectedTeamsList); on("participant-removed-from-queues", handleParticipantRemovedFromQueues); on("queue-eligibility-pruned", handleQueueEligibilityPruned); on("queue-updated", handleQueueUpdated); on("pick-replaced", handlePickReplaced); on("draft-rolled-back", handleDraftRolledBack); on("draft-state-sync", handleDraftStateSync); return () => { off("pick-made", handlePickMade); off("timer-update", handleTimerUpdate); off("draft-paused", handleDraftPaused); off("draft-resumed", handleDraftResumed); off("draft-completed", handleDraftCompleted); off("autodraft-updated", handleAutodraftUpdated); off("team-connected", handleTeamConnected); off("team-disconnected", handleTeamDisconnected); off("connected-teams-list", handleConnectedTeamsList); off("participant-removed-from-queues", handleParticipantRemovedFromQueues); off("queue-eligibility-pruned", handleQueueEligibilityPruned); off("queue-updated", handleQueueUpdated); off("pick-replaced", handlePickReplaced); off("draft-rolled-back", handleDraftRolledBack); off("draft-state-sync", handleDraftStateSync); }; // `on`/`off` are stable useCallback refs. `socketVersion` increments whenever // useDraftSocket creates a new socket instance, ensuring this effect re-runs // and re-registers all handlers on the fresh socket. // `draftSlots`, `userTeam`, and `season.league.name` are intentionally omitted: // they come from useLoaderData and are immutable for the lifetime of the page load. // eslint-disable-next-line react-hooks/exhaustive-deps }, [on, off, socketVersion]); // Persist sidebar collapsed state useEffect(() => { if (typeof window !== "undefined") { localStorage.setItem("draftSidebarCollapsed", JSON.stringify(sidebarCollapsed)); } }, [sidebarCollapsed]); // Queue handlers const handleAddToQueue = useCallback(async (participantId: string) => { if (!userTeam) return; // Check if participant is already in queue (client-side) const alreadyInQueue = queue.some((item) => item.participantId === participantId); if (alreadyInQueue) { toast.error("This participant is already in your queue"); return; } // Optimistic update - create a temporary queue item const now = new Date(); const tempQueueItem: QueueItem = { id: `temp-${Date.now()}`, participantId, queuePosition: queue.length + 1, seasonId: season.id, teamId: userTeam.id, createdAt: now, updatedAt: now, }; // Immediately update the UI setQueue((prev) => [...prev, tempQueueItem]); const formData = new FormData(); formData.append("seasonId", season.id); formData.append("teamId", userTeam.id); formData.append("participantId", participantId); pendingQueueMutationsRef.current++; try { const response = await authFetch("/api/queue/add", { method: "POST", body: formData, }); if (!response) { setQueue((prev) => prev.filter((item) => item.id !== tempQueueItem.id)); return; } if (response.ok) { const data = await response.json(); // Replace temp item with real item from server setQueue((prev) => prev.map((item) => item.id === tempQueueItem.id ? data.queueItem : item ) ); } else { // Revert the optimistic update on error setQueue((prev) => prev.filter((item) => item.id !== tempQueueItem.id)); const error = await response.json(); toast.error(error.error || "Failed to add to queue"); } } catch { // Revert the optimistic update on network error setQueue((prev) => prev.filter((item) => item.id !== tempQueueItem.id)); toast.error("Network error - failed to add to queue"); } finally { pendingQueueMutationsRef.current--; } // queue is read directly (not via functional updater) for the duplicate check and // optimistic position, so it must be a dep. This is fine: AvailableParticipantsSection // already re-renders when queue changes (it receives queue as a prop). }, [userTeam, queue, season.id, authFetch]); const handleRemoveFromQueue = useCallback(async (queueId: string) => { if (!userTeam) return; // Optimistically remove the item immediately let previousQueue: QueueItem[] = []; setQueue((prev) => { previousQueue = prev; return prev.filter((item) => item.id !== queueId); }); const formData = new FormData(); formData.append("queueId", queueId); formData.append("teamId", userTeam.id); pendingQueueMutationsRef.current++; try { const response = await authFetch("/api/queue/remove", { method: "POST", body: formData, }); if (!response) { setQueue(previousQueue); return; } if (response.ok) { const data = await response.json(); setQueue(data.queue); } else { setQueue(previousQueue); const error = await response.json(); toast.error(error.error || "Failed to remove from queue"); } } catch { setQueue(previousQueue); toast.error("Network error - failed to remove from queue"); } finally { pendingQueueMutationsRef.current--; } }, [userTeam, authFetch]); const handleReorderQueue = useCallback(async (participantIds: string[]) => { if (!userTeam) return; // Optimistically reorder the queue immediately let previousQueue: QueueItem[] = []; setQueue((prev) => { previousQueue = prev; return participantIds .map((pid) => prev.find((item) => item.participantId === pid)) .filter((item): item is QueueItem => item !== undefined); }); const formData = new FormData(); formData.append("teamId", userTeam.id); formData.append("seasonId", season.id); formData.append("participantIds", JSON.stringify(participantIds)); pendingQueueMutationsRef.current++; try { const response = await authFetch("/api/queue/reorder", { method: "POST", body: formData, }); if (!response) { setQueue(previousQueue); return; } if (response.ok) { const data = await response.json(); setQueue(data.queue); } else { const error = await response.json(); setQueue(previousQueue); toast.error(error.error || "Failed to reorder queue"); } } catch { setQueue(previousQueue); toast.error("Network error - failed to reorder queue"); } finally { pendingQueueMutationsRef.current--; } }, [userTeam, season.id, authFetch]); const handleStartDraft = async () => { try { const formData = new FormData(); formData.append("seasonId", season.id); const response = await authFetch("/api/draft/start", { method: "POST", body: formData, }); if (!response) return; if (response.ok) { revalidate(); } else { const error = await response.json(); toast.error(error.error || "Failed to start draft"); } } catch { toast.error("Network error - failed to start draft"); } }; const handlePauseDraft = async () => { const formData = new FormData(); formData.append("seasonId", season.id); try { const response = await authFetch("/api/draft/pause", { method: "POST", body: formData, }); if (!response) return; if (response.ok) { setIsPaused(true); } else { const error = await response.json(); toast.error(error.error || "Failed to pause draft"); } } catch { toast.error("Network error - failed to pause draft"); } }; const handleResumeDraft = async () => { const formData = new FormData(); formData.append("seasonId", season.id); try { const response = await authFetch("/api/draft/resume", { method: "POST", body: formData, }); if (!response) return; if (response.ok) { setIsPaused(false); } else { const error = await response.json(); toast.error(error.error || "Failed to resume draft"); } } catch { toast.error("Network error - failed to resume draft"); } }; const handleMakePick = useCallback(async (participantId: string) => { try { const formData = new FormData(); formData.append("seasonId", season.id); formData.append("participantId", participantId); const response = await authFetch("/api/draft/make-pick", { method: "POST", body: formData, }); if (!response) return; if (!response.ok) { const error = await response.json(); toast.error(error.error || "Failed to make pick"); } } catch { toast.error("Network error - failed to make pick"); } }, [season.id, authFetch]); const handleForceAutopick = useCallback(async (pickNumber: number, teamId: string) => { try { const formData = new FormData(); formData.append("seasonId", season.id); formData.append("teamId", teamId); formData.append("pickNumber", pickNumber.toString()); const response = await authFetch("/api/draft/force-autopick", { method: "POST", body: formData, }); if (!response) return; if (!response.ok) { const error = await response.json(); toast.error(error.error || "Failed to force autopick"); } } catch { toast.error("Network error - failed to force autopick"); } }, [season.id, authFetch]); const handleForceManualPick = async (participantId: string) => { if (!selectedPickSlot) return; try { const formData = new FormData(); formData.append("seasonId", season.id); formData.append("teamId", selectedPickSlot.teamId); formData.append("participantId", participantId); formData.append("pickNumber", selectedPickSlot.pickNumber.toString()); const response = await authFetch("/api/draft/force-manual-pick", { method: "POST", body: formData, }); if (!response) return; if (response.ok) { setForcePickDialogOpen(false); setSelectedPickSlot(null); } else { const error = await response.json(); toast.error(error.error || "Failed to force manual pick"); } } catch { toast.error("Network error - failed to force manual pick"); } }; const handleReplacePickOpen = useCallback((pickNumber: number, teamId: string) => { const pick = picks.find((p) => p.pickNumber === pickNumber); if (!pick) { toast.error("Pick not found — try refreshing the page"); return; } setReplacePickSlot({ pickNumber, teamId, oldParticipantId: pick.participant.id }); setReplacePickDialogOpen(true); // picks is read directly to find the pick being replaced. }, [picks]); const handleReplacePick = async (participantId: string) => { if (!replacePickSlot) return; try { const formData = new FormData(); formData.append("seasonId", season.id); formData.append("teamId", replacePickSlot.teamId); formData.append("participantId", participantId); formData.append("pickNumber", replacePickSlot.pickNumber.toString()); const response = await authFetch("/api/draft/replace-pick", { method: "POST", body: formData, }); if (!response) return; if (response.ok) { setReplacePickDialogOpen(false); setReplacePickSlot(null); } else { const error = await response.json(); toast.error(error.error || "Failed to replace pick"); } } catch { toast.error("Network error - failed to replace pick"); } }; const handleRollbackToPick = useCallback((pickNumber: number) => { setRollbackPickNumber(pickNumber); setRollbackConfirmOpen(true); }, []); const handleConfirmRollback = async () => { if (!rollbackPickNumber || isRollingBack) return; setIsRollingBack(true); try { const formData = new FormData(); formData.append("seasonId", season.id); formData.append("pickNumber", rollbackPickNumber.toString()); const response = await authFetch("/api/draft/rollback", { method: "POST", body: formData, }); setIsRollingBack(false); if (!response) return; if (response.ok) { setRollbackConfirmOpen(false); setRollbackPickNumber(null); } else { const error = await response.json(); toast.error(error.error || "Failed to roll back draft"); } } catch { setIsRollingBack(false); toast.error("Network error - failed to roll back draft"); } }; const handleAdjustTimeBankOpen = useCallback((teamId: string) => { setTimeBankTeamId(teamId); setTimeBankAmount("1"); setTimeBankUnit("minutes"); setTimeBankDirection("add"); setTimeBankDialogOpen(true); }, []); const handleSetAutodraftOpen = useCallback((teamId: string) => { setCommissionerAutodraftTeamId(teamId); setCommissionerAutodraftDialogOpen(true); }, []); const handleCommissionerAutodraftUpdate = useCallback(() => { setCommissionerAutodraftDialogOpen(false); setCommissionerAutodraftTeamId(null); }, []); const handleConfirmAdjustTimeBank = async () => { if (!timeBankTeamId || isAdjustingTimeBank) return; const amount = parseFloat(timeBankAmount); if (isNaN(amount) || amount <= 0) { toast.error("Please enter a valid positive amount"); return; } const unitMultipliers = { seconds: 1, minutes: 60, hours: 3600 }; const totalSeconds = Math.round(amount * unitMultipliers[timeBankUnit]); if (totalSeconds === 0) { toast.error("Adjustment rounds to 0 seconds — please enter a larger value"); return; } const adjustment = timeBankDirection === "add" ? totalSeconds : -totalSeconds; setIsAdjustingTimeBank(true); try { const formData = new FormData(); formData.append("seasonId", season.id); formData.append("teamId", timeBankTeamId); formData.append("adjustment", adjustment.toString()); const response = await authFetch("/api/draft/adjust-time-bank", { method: "POST", body: formData, }); if (!response) return; if (response.ok) { const data = await response.json(); setTeamTimers((prev) => ({ ...prev, [timeBankTeamId]: data.timeRemaining })); const teamName = draftSlots.find((s) => s.team.id === timeBankTeamId)?.team.name; toast.success(`Time bank adjusted for ${teamName}`); setTimeBankDialogOpen(false); setTimeBankTeamId(null); } else { const error = await response.json(); toast.error(error.error || "Failed to adjust time bank"); } } catch { toast.error("Network error — failed to adjust time bank"); } finally { setIsAdjustingTimeBank(false); } }; const handleForceManualPickOpen = useCallback((pickNumber: number, teamId: string) => { setSelectedPickSlot({ pickNumber, teamId }); setForcePickDialogOpen(true); }, []); // Calculate current round const currentRound = draftSlots.length > 0 ? Math.ceil(currentPick / draftSlots.length) : 1; // Determine whose turn it is const currentDraftSlot = useMemo( () => getTeamForPick(currentPick, draftSlots), [currentPick, draftSlots] ); const isMyTurn = !!( userTeam && currentDraftSlot && currentDraftSlot.team.id === userTeam.id ); const canPick = isMyTurn && season.status === "draft" && !isPaused; // Only team owner on their turn when draft is active const currentDraftSlotOwnerName = currentDraftSlot ? ownerMap[currentDraftSlot.team.id] : undefined; const currentClockTime = currentDraftSlot ? teamTimers[currentDraftSlot.team.id] : undefined; const currentClockColor = getTimerColorClass(currentClockTime); // Generate snake draft grid structure const draftGrid = useMemo(() => { const teamCount = draftSlots.length; const rounds = season.draftRounds; const pickMap = new Map(picks.map((p) => [p.pickNumber, p])); const grid: Array< Array<{ pickNumber: number; round: number; pickInRound: number; teamId: string; pick?: (typeof draftPicks)[number]; }> > = []; for (let round = 1; round <= rounds; round++) { const roundPicks = []; const isOddRound = round % 2 === 1; for (let i = 0; i < teamCount; i++) { const pickInRound = i + 1; const teamIndex = isOddRound ? i : teamCount - 1 - i; const pickNumber = (round - 1) * teamCount + pickInRound; const teamId = draftSlots[teamIndex]?.team.id; const pick = pickMap.get(pickNumber); roundPicks.push({ pickNumber, round, pickInRound, teamId, pick }); } grid.push(roundPicks); } return grid; }, [picks, draftSlots, season.draftRounds]); // Get drafted participant IDs for filtering const draftedParticipantIds = useMemo( () => new Set(picks.map((p) => p.participant.id)), [picks] ); // Sport IDs where the current user's team has already made at least one pick const userDraftedSportIds = useMemo(() => { if (!userTeam) return new Set(); return new Set( picks .filter((p) => p.team.id === userTeam.id) .map((p) => p.sport.id) ); }, [picks, userTeam]); // Sport names where the current user's team has already made at least one pick const userDraftedSportNames = useMemo(() => { if (!userTeam) return new Set(); return new Set( picks .filter((p) => p.team.id === userTeam.id) .map((p) => p.sport.name) ); }, [picks, userTeam]); // Get unique sports for filter dropdown const uniqueSports = useMemo( () => Array.from( new Set(availableParticipants.map((p) => p.sport.name)) ).sort(), [availableParticipants] ); // Filter participants based on search, sport, drafted status, and eligibility const filteredParticipants = useMemo(() => { const sportFilterSet = new Set(sportFilters); return availableParticipants.filter((participant) => { // Drafted filter - hide drafted participants by default if (hideDrafted && draftedParticipantIds.has(participant.id)) { return false; } // Eligibility filter - hide ineligible participants by default (if user has a team) if (hideIneligible && eligibility) { const isEligible = eligibility.eligibleSportIds.has(participant.sport.id); if (!isEligible) { return false; } } // Hide completed sports filter - hide participants from sports already drafted by user's team if (hideCompletedSports && userDraftedSportIds.has(participant.sport.id)) { return false; } // Search filter if ( searchQuery && !participant.name.toLowerCase().includes(searchQuery.toLowerCase()) ) { return false; } // Sport filter if (sportFilterSet.size > 0 && !sportFilterSet.has(participant.sport.name)) { return false; } return true; }); }, [availableParticipants, hideDrafted, hideIneligible, hideCompletedSports, eligibility, draftedParticipantIds, userDraftedSportIds, searchQuery, sportFilters]); // Shared component props — defined once to avoid duplication between desktop and mobile layouts const handleHideCompletedSportsChange = useCallback((hide: boolean) => { setHideCompletedSports(hide); if (hide) { setSportFilters((prev) => prev.filter((s) => !userDraftedSportNames.has(s))); } }, [userDraftedSportNames]); const miniDraftGrid = useMemo(() => ({ draftSlots, draftGrid, currentPick, currentRound, ownerMap, teamTimers, autodraftStatus, seasonStatus: season.status, draftPaused: isPaused, onForceAutopick: isCommissioner ? handleForceAutopick : undefined, onForceManualPickOpen: isCommissioner ? handleForceManualPickOpen : undefined, onReplacePick: isCommissioner ? handleReplacePickOpen : undefined, onRollbackToPick: isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined, }), [draftSlots, draftGrid, currentPick, currentRound, ownerMap, teamTimers, autodraftStatus, season.status, isPaused, isCommissioner, handleForceAutopick, handleForceManualPickOpen, handleReplacePickOpen, handleRollbackToPick, isDraftComplete]); const availableParticipantsSectionProps = useMemo(() => ({ participants: filteredParticipants, participantRanks, miniDraftGrid, searchQuery, sportFilters, hideDrafted, hideIneligible, uniqueSports, draftedParticipantIds, queue, eligibility, canPick, hasTeam: !!userTeam, onSearchChange: setSearchQuery, onSportFiltersChange: setSportFilters, onHideDraftedChange: setHideDrafted, onHideIneligibleChange: setHideIneligible, hideCompletedSports, userDraftedSportNames, onHideCompletedSportsChange: handleHideCompletedSportsChange, onMakePick: handleMakePick, onAddToQueue: handleAddToQueue, onRemoveFromQueue: handleRemoveFromQueue, }), [filteredParticipants, participantRanks, miniDraftGrid, searchQuery, sportFilters, hideDrafted, hideIneligible, uniqueSports, draftedParticipantIds, queue, eligibility, canPick, userTeam, hideCompletedSports, userDraftedSportNames, handleHideCompletedSportsChange, handleMakePick, handleAddToQueue, handleRemoveFromQueue]); const draftGridSectionProps = { draftSlots, draftGrid, currentPick, teamTimers, autodraftStatus, connectedTeams, isCommissioner, ownerMap, seasonStatus: season.status, draftPaused: isPaused, onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined, onSetAutodraftOpen: isCommissioner ? handleSetAutodraftOpen : undefined, onForceAutopick: handleForceAutopick, onForceManualPickOpen: handleForceManualPickOpen, onReplacePick: isCommissioner ? handleReplacePickOpen : undefined, onRollbackToPick: isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined, }; const summaryViewProps = { draftSlots, picks, sports: seasonSportsData, ownerMap, totalRounds: season.draftRounds, }; const rosterViewProps = { draftSlots, picks, sports: seasonSportsData, ownerMap, totalRounds: season.draftRounds, }; const handleAutodraftUpdate = useCallback( (isEnabled: boolean, mode: "next_pick" | "while_on", queueOnly: boolean) => { setUserAutodraft({ isEnabled, mode, queueOnly }); }, [] ); const queueSectionProps = userTeam ? { queue, availableParticipants, canPick, onRemoveFromQueue: handleRemoveFromQueue, onReorder: handleReorderQueue, onMakePick: handleMakePick, } : null; const mobileTabs = userTeam ? [MOBILE_TABS_BASE[0], QUEUE_TAB, ...MOBILE_TABS_BASE.slice(1)] : MOBILE_TABS_BASE; const mobileColCount = mobileTabs.length; return (
{/* Draft Completion Banner */} {isDraftComplete && (
🎉 Draft Complete! The season is now active. {season.league.isPublicDraftBoard && ( <> {" "} View Draft Board )}
)} {/* Standalone Header - No Main Nav */}
{/* Mobile menu button - only show if user has team and sidebar is collapsed */} {userTeam && sidebarCollapsed && ( )} Brackt

Draft Room

Pick Notifications
{/* Commissioner Controls - hidden on mobile (shown in Controls tab) */} {isCommissioner && season.status === "pre_draft" && ( )} {isCommissioner && season.status === "draft" && !isDraftComplete && (
{isPaused ? ( ) : ( )}
)}
{/* Mobile On Clock bar */} {season.status === "draft" && !isDraftComplete && currentDraftSlot && (
{isMyTurn ? "Your Turn!" : "On the Clock"}
{currentDraftSlotOwnerName ?? currentDraftSlot.team.name}
{currentDraftSlotOwnerName && (
{currentDraftSlot.team.name}
)}
{isPaused ? ( Paused ) : ( {formatClockTime(currentClockTime)} )}
)} {/* Desktop Tab Navigation Row */}
setActiveTab(value as "participants" | "board" | "rosters" | "summary") } > Participants Draft Board Rosters Summary {userTeam && ( )}
{/* Main Content — single layout tree based on isMobile to avoid duplicate component instances */}
{isMobile ? (
{mobileTab === "available" && (
)} {mobileTab === "queue" && (
{queueSectionProps ? ( ) : (

Join a team to manage your queue

)}
)} {mobileTab === "board" && (
{boardSubTab !== "pick-list" ? ( ) : (
)}
)} {mobileTab === "teams" && (
{teamsSubTab === "rosters" ? ( ) : ( )}
)} {mobileTab === "controls" && (
{isCommissioner && season.status === "pre_draft" && ( )} {isCommissioner && season.status === "draft" && !isDraftComplete && ( isPaused ? ( ) : ( ) )}
)}
) : (
{userTeam && queueSectionProps && ( } recentPicksSection={} /> )}
)}
{/* Mobile Bottom Nav */} { setForcePickDialogOpen(open); if (!open) setSelectedPickSlot(null); }} title="Force Manual Pick" description={`Select a participant to draft for Pick #${selectedPickSlot?.pickNumber}`} participants={availableParticipants} draftedParticipantIds={draftedParticipantIds} eligibility={forcePickEligibility} uniqueSports={uniqueSports} onSelect={handleForceManualPick} /> { setReplacePickDialogOpen(open); if (!open) setReplacePickSlot(null); }} title="Replace Pick" description={`Select a participant to replace the current pick at slot #${replacePickSlot?.pickNumber}`} participants={availableParticipants} draftedParticipantIds={draftedParticipantIds} allowParticipantId={replacePickSlot?.oldParticipantId} currentParticipantId={replacePickSlot?.oldParticipantId} eligibility={replacePickEligibility} uniqueSports={uniqueSports} onSelect={handleReplacePick} /> {/* Rollback Confirmation Dialog */} { setRollbackConfirmOpen(open); if (!open) setRollbackPickNumber(null); }} > Roll Back Draft {rollbackPickNumber !== null && (() => { const count = picks.filter((p) => p.pickNumber >= rollbackPickNumber).length; return `This will erase ${count} pick${count === 1 ? "" : "s"} (pick #${rollbackPickNumber} through the most recent) and return the draft to pick #${rollbackPickNumber}. This cannot be undone.`; })()} {/* Time Bank Adjustment Dialog */} { setTimeBankDialogOpen(open); if (!open) setTimeBankTeamId(null); }} > Adjust Time Bank {timeBankTeamId ? `Adjusting time for ${draftSlots.find((s) => s.team.id === timeBankTeamId)?.team.name}` : "Adjust a team's time bank"}
setTimeBankAmount(e.target.value)} className="flex-1 px-3 py-2 border rounded-md bg-background text-foreground text-base md:text-sm" placeholder="Amount" />
{/* Commissioner Autodraft Override Dialog */} { setCommissionerAutodraftDialogOpen(open); if (!open) setCommissionerAutodraftTeamId(null); }} > Set Autodraft {commissionerAutodraftTeamId ? `Change autodraft for ${draftSlots.find((s) => s.team.id === commissionerAutodraftTeamId)?.team.name ?? "this team"}` : "Set a team's autodraft settings"} {commissionerAutodraftTeamId && ( )} {/* Connection Overlay - blocks interaction until socket connects */} {/* Auth Recovery Overlay - blocks interaction when session expires */}
); }