brackt/app/routes/leagues/$leagueId.draft.$seasonId.tsx
Chris Parsons 77c0f47552
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m30s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m19s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Fix draft pause clock: use Math.ceil consistently via shared msToSeconds helper
- Pause snapshot used Math.floor while the client display used Math.ceil,
  causing the visible clock to round down by 1s on pause click
- draft-state-sync also used Math.floor for reconnecting clients
- Extract msToSeconds() to app/lib/draft-timer.ts as the single source of
  truth for all ms→seconds conversions across server and client

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 16:39:53 -07:00

1785 lines
66 KiB
TypeScript

import { useLoaderData, Link, useRevalidator, redirect, useNavigate } 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 { getTeamWatchlist } from "~/models/watchlist";
import { findSeasonWithTeamsAndLeague } from "~/models/season";
import { findDraftSlotsBySeasonId } from "~/models/draft-slot";
import { getDraftPicksForSeason } from "~/models/draft-pick";
import { getDraftParticipants } from "~/models/season-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 { resolveUserAvatarData } from "~/lib/avatar-data";
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, AutodraftSettings } from "~/components/AutodraftSettings";
import { toast } from "sonner";
import { clientExpiresAt, formatClockTime, getTimerColorClass, msToSeconds } 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 });
}
if (season.draftCompletedAt) {
const elapsed = Date.now() - season.draftCompletedAt.getTime();
if (elapsed >= 5 * 60 * 1000) {
return redirect(`/leagues/${leagueId}/draft-board/${seasonId}`);
}
}
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 loaderTimerNow = Date.now();
const userQueue = userTeam
? await getTeamQueue(userTeam.id).catch((err) => {
logger.error("[DraftLoader] Failed to load queue, defaulting to empty:", err);
return [];
})
: [];
const userWatchlist = userTeam
? await getTeamWatchlist(userTeam.id, seasonId).catch((err) => {
logger.error("[DraftLoader] Failed to load watchlist, 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<string, string> = {};
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<string, string | null> =
season.overnightPauseMode === "per_user"
? Object.fromEntries(
draftSlots.map((slot) => [
slot.team.id,
slot.team.ownerId ? (userById.get(slot.team.ownerId)?.timezone || null) : null,
])
)
: {};
const enrichedDraftSlots = draftSlots.map((slot) => {
const ownerUser = slot.team.ownerId ? userById.get(slot.team.ownerId) : undefined;
return {
...slot,
team: {
...slot.team,
ownerAvatarData: ownerUser ? resolveUserAvatarData(ownerUser) : null,
},
};
});
return {
season,
draftSlots: enrichedDraftSlots,
draftPicks,
availableParticipants,
userTeam,
userQueue,
userWatchlist: userWatchlist.map((w) => w.participantId),
timers,
loaderTimerNow,
autodraftSettings,
userAutodraftSettings,
isCommissioner: !!isCommissioner,
currentUserId: userId,
ownerMap,
teamTimezoneMap,
};
}
export default function DraftRoom() {
const {
season,
draftSlots,
draftPicks,
availableParticipants: initialAvailableParticipants,
userTeam,
userQueue,
userWatchlist,
timers,
loaderTimerNow,
autodraftSettings,
userAutodraftSettings,
isCommissioner,
currentUserId,
ownerMap,
teamTimezoneMap,
} = useLoaderData<typeof loader>();
const availableParticipants = initialAvailableParticipants;
const [bracktVorps, setBracktVorps] = useState<Map<string, number>>(() => new Map());
const sortedAvailableParticipants = useMemo(() => {
if (bracktVorps.size === 0) return availableParticipants;
return availableParticipants.toSorted((a, b) => {
const aVorp = bracktVorps.get(a.id) ?? parseFloat(a.vorpValue ?? "0");
const bVorp = bracktVorps.get(b.id) ?? parseFloat(b.vorpValue ?? "0");
const diff = bVorp - aVorp;
return diff !== 0 ? diff : a.name.localeCompare(b.name);
});
}, [availableParticipants, bracktVorps]);
const navigate = useNavigate();
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,
watchedParticipantIds, setWatchedParticipantIds,
showOnlyWatched, setShowOnlyWatched,
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,
roomClosed, setRoomClosed,
isSyncing, setIsSyncing,
} = useDraftRoomState({
draftPicks,
season,
userTeam,
isCommissioner,
autodraftSettings,
timers,
draftSlots,
userQueue,
initialWatchedParticipantIds: userWatchlist,
});
// 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);
}, []);
const secondsUntilAutoStart = useMemo(() => {
if (season.status !== "pre_draft" || !season.autoStartDraft || !season.draftDateTime) return null;
const diff = Math.ceil((new Date(season.draftDateTime).getTime() - nowForPauseCheck.getTime()) / 1000);
return diff > 0 ? diff : 0;
}, [season.status, season.autoStartDraft, season.draftDateTime, nowForPauseCheck]);
const draftBoardUrl = `/leagues/${season.leagueId}/draft-board/${season.id}`;
// Capture a client-side timestamp the moment isDraftComplete first becomes true.
// When the draft completes via socket event the loader hasn't revalidated yet,
// so season.draftCompletedAt is null and we need a fallback baseline for the countdown.
const draftCompletedAtClientRef = useRef<number | null>(null);
if (isDraftComplete && draftCompletedAtClientRef.current === null) {
draftCompletedAtClientRef.current = season.draftCompletedAt?.getTime() ?? Date.now();
}
const roomClosureCountdown = useMemo(() => {
if (!isDraftComplete) return null;
const baseMs = season.draftCompletedAt?.getTime() ?? draftCompletedAtClientRef.current ?? Date.now();
const elapsed = (nowForPauseCheck.getTime() - baseMs) / 1000;
return Math.max(0, Math.ceil(300 - elapsed));
}, [isDraftComplete, season.draftCompletedAt, nowForPauseCheck]);
useEffect(() => {
if (!isDraftComplete) return;
if (roomClosed) {
navigate(draftBoardUrl);
return;
}
if (roomClosureCountdown !== null && roomClosureCountdown <= 0) {
navigate(draftBoardUrl);
}
}, [isDraftComplete, roomClosed, roomClosureCountdown, navigate, draftBoardUrl]);
// 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<string>();
const result = new Set<string>();
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,
isRevalidatingRef,
pendingPicksDuringRevalidationRef,
pendingQueueMutationsRef,
} = useDraftAuthRecovery({
reconnectCount,
revalidate,
revalidatorState,
currentUserId,
userAutodraftSettings,
draftPicks,
season,
userQueue,
timers,
autodraftSettings,
setPicks,
setCurrentPick,
setIsPaused,
setIsDraftComplete,
setQueue,
setTeamTimers,
setAutodraftStatus,
setIsSyncing,
});
useEffect(() => {
if (reconnectCount <= 0) return;
setIsSyncing(true);
const id = setTimeout(() => setIsSyncing(false), 5000);
return () => clearTimeout(id);
}, [reconnectCount, setIsSyncing]);
// 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<string, { id: string; name: string }>();
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<string, { overallRank: number; sportRank: number }>();
const sportCounters = new Map<string, number>();
sortedAvailableParticipants.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;
}, [sortedAvailableParticipants]);
// 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]);
const [animatingOutParticipantIds, setAnimatingOutParticipantIds] = useState<Set<string>>(new Set());
const [pickAnimationSignal, setPickAnimationSignal] = useState<{ id: string; seq: number } | null>(null);
const pickSeqRef = useRef(0);
const animationTimersRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
const addAnimatingOut = useCallback((id: string) => {
setAnimatingOutParticipantIds((prev) => new Set([...prev, id]));
pickSeqRef.current += 1;
setPickAnimationSignal({ id, seq: pickSeqRef.current });
const timer = setTimeout(() => {
setAnimatingOutParticipantIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
animationTimersRef.current.delete(timer);
}, 650);
animationTimersRef.current.add(timer);
}, []);
useEffect(() => () => { animationTimersRef.current.forEach(clearTimeout); }, []);
// Client-side countdown: always anchored to the client clock (not the server timestamp) to avoid skew.
const [pickTimerExpiresAt, setPickTimerExpiresAt] = useState<{ teamId: string; expiresAt: number } | null>(() => {
// loaderTimerNow is server-side Date.now() — subtract from server expiresAt to get ms remaining
// without skew, then re-anchor to client clock.
const active = timers.find((t) => t.picksExpiresAt && t.picksExpiresAt.getTime() > loaderTimerNow);
if (!active?.picksExpiresAt) return null;
const secondsRemaining = msToSeconds(active.picksExpiresAt.getTime() - loaderTimerNow);
return { teamId: active.teamId, expiresAt: clientExpiresAt(secondsRemaining) };
});
useEffect(() => {
if (!pickTimerExpiresAt) return;
const { teamId, expiresAt } = pickTimerExpiresAt;
const tick = () => {
const remaining = msToSeconds(expiresAt - Date.now());
setTeamTimers((prev) => prev[teamId] === remaining ? prev : { ...prev, [teamId]: remaining });
};
tick(); // immediate update so display is accurate on mount
const id = setInterval(tick, 500);
return () => clearInterval(id);
}, [pickTimerExpiresAt, setTeamTimers]);
useDraftSocketEvents({
on,
off,
socketVersion,
userTeam,
draftSlots,
leagueName: season.league.name,
isRevalidatingRef,
pendingPicksDuringRevalidationRef,
pendingQueueMutationsRef,
sendNotificationRef,
notificationsModeRef,
setPicks,
setCurrentPick,
setIsPaused,
setIsDraftComplete,
setAutodraftStatus,
setUserAutodraft,
setConnectedTeams,
setQueue,
setTeamTimers,
setPickTimerExpiresAt,
setIsOvernightPause,
setOvernightResumesAt,
setWatchedParticipantIds,
setRoomClosed,
setIsSyncing,
setBracktVorps,
setAnimatingOutParticipantId: addAnimatingOut,
});
// Persist sidebar collapsed state
useEffect(() => {
if (typeof window !== "undefined") {
localStorage.setItem("draftSidebarCollapsed", JSON.stringify(sidebarCollapsed));
}
}, [sidebarCollapsed]);
// Revalidate loader data when the draft auto-starts so the pre-draft UI transitions without a refresh.
useEffect(() => {
const handleDraftStarted = () => revalidate();
on("draft-started", handleDraftStarted);
return () => off("draft-started", handleDraftStarted);
}, [on, off, revalidate]);
// 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 handleToggleWatchlist = useCallback(async (participantId: string) => {
if (!userTeam) return;
setWatchedParticipantIds((prev) => {
const next = new Set(prev);
if (next.has(participantId)) {
next.delete(participantId);
} else {
next.add(participantId);
}
return next;
});
const formData = new FormData();
formData.append("seasonId", season.id);
formData.append("teamId", userTeam.id);
formData.append("participantId", participantId);
try {
const response = await authFetch("/api/watchlist/toggle", {
method: "POST",
body: formData,
});
if (!response) {
setWatchedParticipantIds((prev) => {
const next = new Set(prev);
if (next.has(participantId)) { next.delete(participantId); } else { next.add(participantId); }
return next;
});
return;
}
if (!response.ok) {
setWatchedParticipantIds((prev) => {
const next = new Set(prev);
if (next.has(participantId)) { next.delete(participantId); } else { next.add(participantId); }
return next;
});
const error = await response.json();
toast.error(error.error || "Failed to update watchlist");
}
} catch {
setWatchedParticipantIds((prev) => {
const next = new Set(prev);
if (next.has(participantId)) { next.delete(participantId); } else { next.add(participantId); }
return next;
});
toast.error("Network error - failed to update watchlist");
}
}, [userTeam, season.id, authFetch, setWatchedParticipantIds]);
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;
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<string>();
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<string>();
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 sortedAvailableParticipants.filter((participant) => {
// Drafted filter - hide drafted participants by default, but keep animating-out rows visible
if (hideDrafted && draftedParticipantIds.has(participant.id) && !animatingOutParticipantIds.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;
}
// Watched only filter
if (showOnlyWatched && !watchedParticipantIds.has(participant.id)) {
return false;
}
return true;
});
}, [sortedAvailableParticipants, hideDrafted, hideIneligible, hideCompletedSports, eligibility, draftedParticipantIds, animatingOutParticipantIds, userDraftedSportIds, searchQuery, sportFilters, showOnlyWatched, watchedParticipantIds]);
// 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,
watchedParticipantIds,
onToggleWatchlist: handleToggleWatchlist,
showOnlyWatched,
onShowOnlyWatchedChange: setShowOnlyWatched,
pickAnimationSignal,
}), [filteredParticipants, participantRanks, miniDraftGrid, searchQuery, sportFilters, hideDrafted, hideIneligible, uniqueSports, draftedParticipantIds, queue, eligibility, canPick, userTeam, hideCompletedSports, userDraftedSportNames, handleHideCompletedSportsChange, handleMakePick, handleAddToQueue, handleRemoveFromQueue, setSearchQuery, setSportFilters, setHideDrafted, setHideIneligible, projectedPicks, watchedParticipantIds, handleToggleWatchlist, showOnlyWatched, setShowOnlyWatched, pickAnimationSignal]);
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: sortedAvailableParticipants,
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 (
<div className="h-dvh bg-background flex flex-col overflow-hidden">
{/* Draft Completion Banner */}
{isDraftComplete && (
<div className="bg-emerald-500/20 border-b border-emerald-500/30 text-emerald-400 px-4 py-3 text-center font-semibold flex-shrink-0 flex items-center justify-center gap-2 flex-wrap">
<span>Draft Complete! The season is now active.</span>
<a
href={draftBoardUrl}
className="inline-flex items-center gap-1 bg-emerald-500/30 hover:bg-emerald-500/40 text-emerald-300 px-3 py-1 rounded-md text-sm transition-colors"
>
View Draft Board
</a>
{roomClosureCountdown !== null && roomClosureCountdown > 0 && (
<span aria-live="polite" aria-atomic="true" className="text-sm text-emerald-500">
Room closes in {Math.floor(roomClosureCountdown / 60)}:{String(roomClosureCountdown % 60).padStart(2, "0")}
</span>
)}
</div>
)}
{/* Standalone Header - No Main Nav */}
<div className="border-b bg-card flex-shrink-0">
<div className="w-full px-4 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{/* Mobile menu button - only show if user has team and sidebar is collapsed */}
{userTeam && sidebarCollapsed && (
<Button
variant="ghost"
size="icon"
onClick={() => setSidebarCollapsed(false)}
className="lg:hidden"
aria-label="Open sidebar"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</Button>
)}
<Link to="/">
<img src={logomarkUrl} alt="Brackt" className="h-8" />
</Link>
<h1 className="text-lg md:text-xl font-bold">
Draft Room
</h1>
</div>
<div className="flex items-center gap-3">
<div className="hidden md:flex items-center gap-2 relative">
<span className="text-xs text-muted-foreground">Push Notifications</span>
<NotificationSettings
enabled={notificationsEnabled}
onEnabledChange={setNotificationsEnabled}
mode={notificationsMode}
onModeChange={setNotificationsMode}
permissionState={notificationsPermission}
switchOnly
/>
</div>
{/* Commissioner Controls - hidden on mobile (shown in Controls tab) */}
{isCommissioner && (
<div className="hidden md:flex">
<CommissionerDraftControls
seasonStatus={season.status}
isDraftComplete={isDraftComplete}
isPaused={isPaused}
onStart={handleStartDraft}
onPause={handlePauseDraft}
onResume={handleResumeDraft}
autoStartDraft={season.autoStartDraft}
secondsUntilAutoStart={secondsUntilAutoStart}
/>
</div>
)}
<Button variant="outline" asChild className="hidden md:flex">
<Link to={`/leagues/${season.leagueId}`}>Exit Draft Room</Link>
</Button>
</div>
</div>
</div>
</div>
{/* Mobile On Clock bar */}
{season.status === "draft" && !isDraftComplete && currentDraftSlot && (
<div aria-live="assertive" aria-atomic="true" className={`md:hidden flex-shrink-0 flex items-center justify-between px-4 py-2 border-b ${
isMyTurn
? isOvernightPause
? "bg-blue-950/40 border-blue-800/40"
: "bg-electric/25 border-electric"
: "bg-muted/30"
}`}>
<div>
<div className={`text-xs font-bold uppercase tracking-wider ${
isMyTurn
? isOvernightPause ? "text-blue-400" : "text-electric"
: "text-muted-foreground"
}`}>
{isMyTurn
? isOvernightPause ? "Overnight Pause" : "Your Turn!"
: "On the Clock"}
</div>
<div className="text-sm font-bold">
{currentDraftSlotOwnerName ?? currentDraftSlot.team.name}
</div>
{currentDraftSlotOwnerName && (
<div className="text-xs text-muted-foreground">{currentDraftSlot.team.name}</div>
)}
</div>
{isPaused ? (
<span className="text-sm font-bold text-amber-accent">Paused</span>
) : isOvernightPause ? (
<span className={`text-xl font-mono font-bold tabular-nums ${currentClockColor}`}>
🌙 {formatClockTime(currentClockTime)}
</span>
) : (
<span className={`text-xl font-mono font-bold tabular-nums ${currentClockColor}`}>
{formatClockTime(currentClockTime)}
</span>
)}
</div>
)}
{/* Desktop Tab Navigation Row */}
<div className={`hidden md:grid md:grid-cols-3 flex-shrink-0 items-center px-4 py-2 border-b transition-all duration-300 ${
isMyTurn && season.status === "draft" && !isDraftComplete
? isOvernightPause
? "bg-blue-950/40 border-blue-800/40"
: "bg-electric/25 border-electric shadow-[0_0_24px_0_rgb(0_200_255_/_0.18)]"
: "bg-card"
}`}>
<Tabs
value={activeTab}
onValueChange={(value) =>
setActiveTab(value as "participants" | "board" | "rosters" | "summary")
}
>
<TabsList>
<TabsTrigger value="participants">Participants</TabsTrigger>
<TabsTrigger value="board">Draft Board</TabsTrigger>
<TabsTrigger value="rosters">Rosters</TabsTrigger>
<TabsTrigger value="summary">Summary</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex justify-center" aria-live="assertive" aria-atomic="true">
{isMyTurn && season.status === "draft" && !isDraftComplete && (
isOvernightPause ? (
<span className="text-sm text-blue-200 text-center leading-snug">
🌙 Overnight pause active timer frozen
{overnightResumesAt && (
<> until {overnightResumesAt.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}</>
)}. You can still pick.
</span>
) : (
<span className="text-base font-bold tracking-wide text-electric">
It&apos;s your turn!
</span>
)
)}
</div>
<div className="flex justify-end">
{userTeam && (
<AutodraftBadgeWithPopover
seasonId={season.id}
teamId={userTeam.id}
isEnabled={userAutodraft.isEnabled}
mode={userAutodraft.mode}
queueOnly={userAutodraft.queueOnly}
isMyTurn={isMyTurn}
onUpdate={handleAutodraftUpdate}
showOvernightNote={season.overnightPauseMode !== "none"}
/>
)}
</div>
</div>
{/* Main Content — single layout tree based on isMobile to avoid duplicate component instances */}
<div className="flex-1 overflow-hidden">
{isMobile ? (
<div className="flex h-full overflow-hidden flex-col">
{mobileTab === "available" && (
<div className="flex flex-col h-full overflow-hidden">
<div className="flex-shrink-0">
<RecentPicksFeed picks={picks} />
</div>
<div className="flex-1 min-h-0 overflow-hidden">
<AvailableParticipantsSection {...availableParticipantsSectionProps} miniDraftGrid={undefined} />
</div>
</div>
)}
{mobileTab === "queue" && (
<div className="h-full overflow-y-auto">
{queueSectionProps ? (
<QueueSection {...queueSectionProps} />
) : (
<p className="text-muted-foreground text-sm text-center py-8 px-4">
Join a team to manage your queue
</p>
)}
</div>
)}
{mobileTab === "board" && (
<div className="flex flex-col h-full overflow-hidden">
<div role="tablist" aria-label="Board view" className="flex-shrink-0 flex gap-1 p-2 border-b">
<button
role="tab"
aria-selected={boardSubTab === "board"}
aria-controls="board-subtab-panel"
onClick={() => setBoardSubTab("board")}
className={`flex-1 py-1.5 text-sm font-medium rounded transition-colors ${
boardSubTab === "board"
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Board
</button>
<button
role="tab"
aria-selected={boardSubTab === "pick-list"}
aria-controls="board-subtab-panel"
onClick={() => setBoardSubTab("pick-list")}
className={`flex-1 py-1.5 text-sm font-medium rounded transition-colors ${
boardSubTab === "pick-list"
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Pick List
</button>
</div>
<div id="board-subtab-panel" role="tabpanel" className="flex-1 overflow-hidden">
{boardSubTab !== "pick-list" ? (
<DraftGridSection {...draftGridSectionProps} />
) : (
<div className="h-full overflow-y-auto p-4">
<SidebarRecentPicks picks={picks} />
</div>
)}
</div>
</div>
)}
{mobileTab === "teams" && (
<div className="flex flex-col h-full overflow-hidden">
<div className="flex-shrink-0 flex gap-1 p-2 border-b">
<button
onClick={() => setTeamsSubTab("rosters")}
className={`flex-1 py-1.5 text-sm font-medium rounded transition-colors ${
teamsSubTab === "rosters"
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Roster
</button>
<button
onClick={() => setTeamsSubTab("summary")}
className={`flex-1 py-1.5 text-sm font-medium rounded transition-colors ${
teamsSubTab === "summary"
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
Summary
</button>
</div>
<div className="flex-1 overflow-hidden">
{teamsSubTab === "rosters" ? (
<TeamRosterView {...rosterViewProps} />
) : (
<DraftSummaryView {...summaryViewProps} />
)}
</div>
</div>
)}
{mobileTab === "controls" && (
<div className="h-full flex flex-col">
<div className="flex-1 overflow-y-auto p-4">
{isCommissioner && season.status === "pre_draft" && (
<CommissionerDraftControls
seasonStatus={season.status}
isDraftComplete={isDraftComplete}
isPaused={isPaused}
onStart={handleStartDraft}
onPause={handlePauseDraft}
onResume={handleResumeDraft}
buttonClassName="w-full min-h-[48px]"
autoStartDraft={season.autoStartDraft}
secondsUntilAutoStart={secondsUntilAutoStart}
/>
)}
{userTeam && (
<AutodraftSettings
seasonId={season.id}
teamId={userTeam.id}
isEnabled={userAutodraft.isEnabled}
mode={userAutodraft.mode}
queueOnly={userAutodraft.queueOnly}
isMyTurn={isMyTurn}
onUpdate={handleAutodraftUpdate}
showOvernightNote={season.overnightPauseMode !== "none"}
/>
)}
</div>
<div className="flex-shrink-0 p-4 border-t flex flex-col gap-3">
{isCommissioner && season.status === "draft" && !isDraftComplete && (
isPaused ? (
<Button className="w-full min-h-[48px]" onClick={handleResumeDraft}>
Resume Draft
</Button>
) : (
<Button className="w-full min-h-[48px]" variant="outline" onClick={handlePauseDraft}>
Pause Draft
</Button>
)
)}
<Button variant="outline" asChild className="w-full min-h-[48px]">
<Link to={`/leagues/${season.leagueId}`}>Exit Draft Room</Link>
</Button>
</div>
</div>
)}
</div>
) : (
<div className="flex h-full overflow-hidden">
{userTeam && queueSectionProps && (
<DraftSidebar
collapsed={sidebarCollapsed}
onCollapsedChange={setSidebarCollapsed}
queueSection={<QueueSection {...queueSectionProps} />}
recentPicksSection={<SidebarRecentPicks picks={picks} />}
/>
)}
<div className="flex-1 overflow-hidden relative">
<div className={`absolute inset-0 ${activeTab === "participants" ? "" : "hidden"}`}>
<AvailableParticipantsSection {...availableParticipantsSectionProps} />
</div>
<div className={`absolute inset-0 ${activeTab === "board" ? "" : "hidden"}`}>
<DraftGridSection {...draftGridSectionProps} />
</div>
<div className={`absolute inset-0 ${activeTab === "rosters" ? "" : "hidden"}`}>
<TeamRosterView {...rosterViewProps} />
</div>
<div className={`absolute inset-0 ${activeTab === "summary" ? "" : "hidden"}`}>
<DraftSummaryView {...summaryViewProps} />
</div>
</div>
</div>
)}
</div>
{/* Mobile Bottom Nav */}
<nav className={`md:hidden flex-shrink-0 border-t bg-card grid`} style={{ gridTemplateColumns: `repeat(${mobileColCount}, 1fr)` }}>
{mobileTabs.map((tab) => {
const showTurnIndicator =
(tab.id === "available" || tab.id === "queue") &&
isMyTurn &&
season.status === "draft" &&
!isDraftComplete;
return (
<button
key={tab.id}
onClick={() => setMobileTab(tab.id as typeof mobileTab)}
className={`relative flex flex-col items-center justify-center py-2 gap-0.5 min-h-[56px] transition-colors ${
mobileTab === tab.id ? "text-electric" : "text-muted-foreground"
}`}
>
<div className="relative">
<tab.Icon className="h-5 w-5" />
{showTurnIndicator && (
<span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-electric" />
)}
</div>
<span className="text-[10px] font-medium">{tab.label}</span>
</button>
);
})}
</nav>
<ParticipantSelectionDialog
open={forcePickDialogOpen && !!selectedPickSlot}
onOpenChange={(open) => {
setForcePickDialogOpen(open);
if (!open) setSelectedPickSlot(null);
}}
title="Force Manual Pick"
description={`Select a participant to draft for Pick #${selectedPickSlot?.pickNumber}`}
participants={sortedAvailableParticipants}
draftedParticipantIds={draftedParticipantIds}
eligibility={forcePickEligibility}
uniqueSports={uniqueSports}
onSelect={handleForceManualPick}
/>
<ParticipantSelectionDialog
open={replacePickDialogOpen && !!replacePickSlot}
onOpenChange={(open) => {
setReplacePickDialogOpen(open);
if (!open) setReplacePickSlot(null);
}}
title="Replace Pick"
description={`Select a participant to replace the current pick at slot #${replacePickSlot?.pickNumber}`}
participants={sortedAvailableParticipants}
draftedParticipantIds={draftedParticipantIds}
allowParticipantId={replacePickSlot?.oldParticipantId}
currentParticipantId={replacePickSlot?.oldParticipantId}
eligibility={replacePickEligibility}
uniqueSports={uniqueSports}
onSelect={handleReplacePick}
/>
{/* Rollback Confirmation Dialog */}
<RollbackConfirmDialog
open={rollbackConfirmOpen}
onOpenChange={(open) => { 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 */}
<TimeBankAdjustmentDialog
open={timeBankDialogOpen}
onOpenChange={(open) => { 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 */}
<CommissionerAutodraftDialog
open={commissionerAutodraftDialogOpen}
onOpenChange={(open) => { 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 */}
<ConnectionOverlay
isConnected={isConnected}
isReconnecting={isReconnecting}
connectionError={connectionError}
isSyncing={isSyncing}
/>
{/* Auth Recovery Overlay - blocks interaction when session expires */}
<AuthRecoveryOverlay isAuthDegraded={authDegraded} />
</div>
);
}