import { useLoaderData, Link, useRevalidator } from "react-router"; import { useDraftSocket } from "~/hooks/useDraftSocket"; import { logger } from "~/lib/logger"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "~/components/ui/button"; import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { auth } from "~/lib/auth.server"; import { useDraftRoomState, type QueueItem } from "~/hooks/useDraftRoomState"; import { useDraftAuthRecovery } from "~/hooks/useDraftAuthRecovery"; import { useDraftSocketEvents } from "~/hooks/useDraftSocketEvents"; import { RollbackConfirmDialog } from "~/components/draft/RollbackConfirmDialog"; import { TimeBankAdjustmentDialog } from "~/components/draft/TimeBankAdjustmentDialog"; import { CommissionerAutodraftDialog } from "~/components/draft/CommissionerAutodraftDialog"; import { CommissionerDraftControls } from "~/components/draft/CommissionerDraftControls"; import { getTeamQueue } from "~/models/draft-queue"; import { findSeasonWithTeamsAndLeague } from "~/models/season"; import { findDraftSlotsBySeasonId } from "~/models/draft-slot"; import { getDraftPicksForSeason } from "~/models/draft-pick"; import { getDraftParticipants } from "~/models/participant"; import { getSeasonTimers } from "~/models/draft-timer"; import { getSeasonAutodraftSettings } from "~/models/autodraft-settings"; import { hasCommissionerRecord } from "~/models/commissioner"; import { findUsersByIds, getUserDisplayName } from "~/models/user"; import { isInOvernightWindow } from "~/lib/overnight-pause"; import logomarkUrl from "../../../public/logomark.svg?url"; 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, getProjectedPicks } from "~/lib/draft-order"; import { useDraftNotifications } from "~/hooks/useDraftNotifications"; import { NotificationSettings } from "~/components/NotificationSettings"; import { 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` }]; } 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 session = await auth.api.getSession({ headers: args.request.headers }); const userId = session?.user.id ?? null; if (!seasonId) { throw new Response("Season ID is required", { status: 400 }); } const season = await findSeasonWithTeamsAndLeague(seasonId); if (!season) { throw new Response("Season not found", { status: 404 }); } const userTeam = userId ? season.teams.find((team) => team.ownerId === userId) : undefined; const isCommissioner = userId ? await hasCommissionerRecord(leagueId, userId) : false; 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, }); } } const [draftSlots, draftPicks, availableParticipants, timers, autodraftSettings] = await Promise.all([ findDraftSlotsBySeasonId(seasonId), getDraftPicksForSeason(seasonId), getDraftParticipants(seasonId), getSeasonTimers(seasonId), getSeasonAutodraftSettings(seasonId), ]); const userQueue = userTeam ? await getTeamQueue(userTeam.id).catch((err) => { logger.error("[DraftLoader] Failed to load queue, defaulting to empty:", err); return []; }) : []; const userAutodraftSettings = userTeam ? (autodraftSettings.find((s) => s.teamId === userTeam.id) ?? null) : null; // Single users query builds both the owner display-name map and the timezone // map for overnight pause moon icons — avoids two separate findUsersByIds calls. const uniqueOwnerIds = [...new Set( draftSlots.map((s) => s.team.ownerId).filter((id): id is string => id !== null) )]; const userRows = uniqueOwnerIds.length ? await findUsersByIds(uniqueOwnerIds) : []; const userById = new Map(userRows.map((u) => [u.id, u])); const ownerMap: Record = {}; for (const slot of draftSlots) { if (slot.team.ownerId) { const user = userById.get(slot.team.ownerId); const name = user ? getUserDisplayName(user) : null; if (name) ownerMap[slot.team.id] = name; } } const teamTimezoneMap: Record = season.overnightPauseMode === "per_user" ? Object.fromEntries( draftSlots.map((slot) => [ slot.team.id, slot.team.ownerId ? (userById.get(slot.team.ownerId)?.timezone || null) : null, ]) ) : {}; return { season, draftSlots, draftPicks, availableParticipants, userTeam, userQueue, timers, autodraftSettings, userAutodraftSettings, isCommissioner: !!isCommissioner, currentUserId: userId, ownerMap, teamTimezoneMap, }; } export default function DraftRoom() { const { season, draftSlots, draftPicks, availableParticipants, userTeam, userQueue, timers, autodraftSettings, userAutodraftSettings, isCommissioner, currentUserId, ownerMap, teamTimezoneMap, } = useLoaderData(); const { revalidate, state: revalidatorState } = useRevalidator(); 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]); const { picks, setPicks, currentPick, setCurrentPick, searchQuery, setSearchQuery, hideDrafted, setHideDrafted, hideIneligible, setHideIneligible, hideCompletedSports, setHideCompletedSports, sportFilters, setSportFilters, queue, setQueue, isPaused, setIsPaused, isDraftComplete, setIsDraftComplete, sidebarCollapsed, setSidebarCollapsed, activeTab, setActiveTab, mobileTab, setMobileTab, teamsSubTab, setTeamsSubTab, boardSubTab, setBoardSubTab, isMobile, autodraftStatus, setAutodraftStatus, connectedTeams, setConnectedTeams, teamTimers, setTeamTimers, forcePickDialogOpen, setForcePickDialogOpen, selectedPickSlot, setSelectedPickSlot, replacePickDialogOpen, setReplacePickDialogOpen, replacePickSlot, setReplacePickSlot, rollbackConfirmOpen, setRollbackConfirmOpen, rollbackPickNumber, setRollbackPickNumber, isRollingBack, setIsRollingBack, timeBankDialogOpen, setTimeBankDialogOpen, timeBankTeamId, setTimeBankTeamId, commissionerAutodraftDialogOpen, setCommissionerAutodraftDialogOpen, commissionerAutodraftTeamId, setCommissionerAutodraftTeamId, timeBankAmount, setTimeBankAmount, timeBankUnit, setTimeBankUnit, timeBankDirection, setTimeBankDirection, isAdjustingTimeBank, setIsAdjustingTimeBank, isOvernightPause, setIsOvernightPause, overnightResumesAt, setOvernightResumesAt, } = useDraftRoomState({ draftPicks, season, userTeam, isCommissioner, autodraftSettings, timers, draftSlots, userQueue, }); // Advances every second so isInOvernightWindow sees the real current time. const [nowForPauseCheck, setNowForPauseCheck] = useState(() => new Date()); useEffect(() => { const id = setInterval(() => setNowForPauseCheck(new Date()), 1_000); return () => clearInterval(id); }, []); // Compute which teams are currently in their overnight pause window. const pausedTeamIds = useMemo(() => { const { overnightPauseMode: mode, overnightPauseStart: start, overnightPauseEnd: end, overnightPauseTimezone: fallbackTz } = season; if (mode === "none" || !start || !end) return new Set(); const result = new Set(); for (const slot of draftSlots) { const tz = mode === "league" ? (fallbackTz || null) : (teamTimezoneMap[slot.team.id] || fallbackTz || null); if (tz && isInOvernightWindow(tz, start, end, nowForPauseCheck)) result.add(slot.team.id); } return result; }, [season, draftSlots, teamTimezoneMap, nowForPauseCheck]); const { authDegraded, authFetch, userAutodraft, setUserAutodraft, userAutodraftRef, isRevalidatingRef, pendingPicksDuringRevalidationRef, pendingQueueMutationsRef, } = useDraftAuthRecovery({ reconnectCount, revalidate, revalidatorState, currentUserId, userAutodraftSettings, draftPicks, season, userQueue, timers, autodraftSettings, setPicks, setCurrentPick, setIsPaused, setIsDraftComplete, setQueue, setTeamTimers, setAutodraftStatus, }); // 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]); useDraftSocketEvents({ on, off, socketVersion, userTeam, draftSlots, leagueName: season.league.name, isRevalidatingRef, pendingPicksDuringRevalidationRef, pendingQueueMutationsRef, userAutodraftRef, sendNotificationRef, notificationsModeRef, setPicks, setCurrentPick, setIsPaused, setIsDraftComplete, setAutodraftStatus, setUserAutodraft, setConnectedTeams, setQueue, setTeamTimers, setIsOvernightPause, setOvernightResumesAt, }); // 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, setQueue, pendingQueueMutationsRef]); 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, setQueue, pendingQueueMutationsRef]); 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, setQueue, pendingQueueMutationsRef]); 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, setReplacePickSlot, setReplacePickDialogOpen]); 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); }, [setRollbackPickNumber, setRollbackConfirmOpen]); 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); }, [setTimeBankTeamId, setTimeBankAmount, setTimeBankUnit, setTimeBankDirection, setTimeBankDialogOpen]); const handleSetAutodraftOpen = useCallback((teamId: string) => { setCommissionerAutodraftTeamId(teamId); setCommissionerAutodraftDialogOpen(true); }, [setCommissionerAutodraftTeamId, setCommissionerAutodraftDialogOpen]); const handleCommissionerAutodraftUpdate = useCallback(() => { setCommissionerAutodraftDialogOpen(false); setCommissionerAutodraftTeamId(null); }, [setCommissionerAutodraftDialogOpen, setCommissionerAutodraftTeamId]); 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); }, [setSelectedPickSlot, setForcePickDialogOpen]); // 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]); const projectedPicks = useMemo(() => { if (!userTeam || season.status !== "draft" || isDraftComplete) return []; return getProjectedPicks({ draftSlots, userTeamId: userTeam.id, currentPick, totalRounds: season.draftRounds, existingPicks: picks.map((p) => ({ pickNumber: p.pickNumber, teamId: p.team.id, })), }); }, [userTeam, season.status, isDraftComplete, draftSlots, currentPick, season.draftRounds, picks]); // 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, setHideCompletedSports, setSportFilters]); const miniDraftGrid = useMemo(() => ({ draftSlots, draftGrid, currentPick, currentRound, ownerMap, teamTimers, autodraftStatus, connectedTeams, seasonStatus: season.status, draftPaused: isPaused, overnightPauseInfo: { isActive: isOvernightPause, resumesAt: overnightResumesAt, pausedTeamIds }, onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined, onSetAutodraftOpen: isCommissioner ? handleSetAutodraftOpen : undefined, onForceAutopick: isCommissioner ? handleForceAutopick : undefined, onForceManualPickOpen: isCommissioner ? handleForceManualPickOpen : undefined, onReplacePick: isCommissioner ? handleReplacePickOpen : undefined, onRollbackToPick: isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined, }), [draftSlots, draftGrid, currentPick, currentRound, ownerMap, teamTimers, autodraftStatus, connectedTeams, season.status, isPaused, isOvernightPause, overnightResumesAt, pausedTeamIds, isCommissioner, handleAdjustTimeBankOpen, handleSetAutodraftOpen, 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, projectedPicks, }), [filteredParticipants, participantRanks, miniDraftGrid, searchQuery, sportFilters, hideDrafted, hideIneligible, uniqueSports, draftedParticipantIds, queue, eligibility, canPick, userTeam, hideCompletedSports, userDraftedSportNames, handleHideCompletedSportsChange, handleMakePick, handleAddToQueue, handleRemoveFromQueue, setSearchQuery, setSportFilters, setHideDrafted, setHideIneligible, projectedPicks]); const draftGridSectionProps = { draftSlots, draftGrid, currentPick, teamTimers, autodraftStatus, connectedTeams, isCommissioner, ownerMap, seasonStatus: season.status, draftPaused: isPaused, overnightPauseInfo: { isActive: isOvernightPause, resumesAt: overnightResumesAt, pausedTeamIds }, onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined, onSetAutodraftOpen: isCommissioner ? handleSetAutodraftOpen : undefined, onForceAutopick: isCommissioner ? handleForceAutopick : undefined, onForceManualPickOpen: isCommissioner ? handleForceManualPickOpen : undefined, 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 }); }, [setUserAutodraft] ); 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 && (
)}
{/* Mobile On Clock bar */} {season.status === "draft" && !isDraftComplete && currentDraftSlot && (
{isMyTurn ? isOvernightPause ? "Overnight Pause" : "Your Turn!" : "On the Clock"}
{currentDraftSlotOwnerName ?? currentDraftSlot.team.name}
{currentDraftSlotOwnerName && (
{currentDraftSlot.team.name}
)}
{isPaused ? ( Paused ) : isOvernightPause ? ( 🌙 {formatClockTime(currentClockTime)} ) : ( {formatClockTime(currentClockTime)} )}
)} {/* Desktop Tab Navigation Row */}
setActiveTab(value as "participants" | "board" | "rosters" | "summary") } > Participants Draft Board Rosters Summary
{isMyTurn && season.status === "draft" && !isDraftComplete && ( isOvernightPause ? ( 🌙 Overnight pause active — timer frozen {overnightResumesAt && ( <> until {overnightResumesAt.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })} )}. You can still pick. ) : ( It's your turn! ) )}
{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); }} pickNumber={rollbackPickNumber} picksCount={rollbackPickNumber !== null ? picks.filter((p) => p.pickNumber >= rollbackPickNumber).length : 0} isRollingBack={isRollingBack} onConfirm={handleConfirmRollback} /> {/* Time Bank Adjustment Dialog */} { setTimeBankDialogOpen(open); if (!open) setTimeBankTeamId(null); }} teamName={draftSlots.find((s) => s.team.id === timeBankTeamId)?.team.name} amount={timeBankAmount} onAmountChange={setTimeBankAmount} unit={timeBankUnit} onUnitChange={setTimeBankUnit} direction={timeBankDirection} onDirectionChange={setTimeBankDirection} isAdjusting={isAdjustingTimeBank} onConfirm={handleConfirmAdjustTimeBank} /> {/* Commissioner Autodraft Override Dialog */} { setCommissionerAutodraftDialogOpen(open); if (!open) setCommissionerAutodraftTeamId(null); }} teamId={commissionerAutodraftTeamId} teamName={draftSlots.find((s) => s.team.id === commissionerAutodraftTeamId)?.team.name} seasonId={season.id} isEnabled={autodraftStatus[commissionerAutodraftTeamId ?? ""]?.isEnabled ?? false} mode={autodraftStatus[commissionerAutodraftTeamId ?? ""]?.mode ?? "next_pick"} queueOnly={autodraftStatus[commissionerAutodraftTeamId ?? ""]?.queueOnly ?? false} onUpdate={handleCommissionerAutodraftUpdate} /> {/* Connection Overlay - blocks interaction until socket connects */} {/* Auth Recovery Overlay - blocks interaction when session expires */}
); }