brackt/app/routes/leagues/$leagueId.draft.$seasonId.tsx
Chris Parsons 3210ab265f
Change participant expectedValue from integer to decimal (#1)
* feat: sync odds-based EV to participants table for draft room ranking

When admin enters futures odds or manual probabilities, the calculated EV
is now synced from participantExpectedValues to participants.expectedValue.
This closes the gap where EVs were calculated but never reflected in the
draft room ranking. Also changes expectedValue from integer to decimal(10,2)
for better precision, and displays "—" for participants with no odds data.

https://claude.ai/code/session_01JWG2zg2EMzCn6RgEufPC1T

* fix: correct expectedValue type to string in participants route

Missed instance of `expectedValue: 0` (number) that failed typecheck
after schema change from integer to decimal.

https://claude.ai/code/session_01JWG2zg2EMzCn6RgEufPC1T

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-02-19 11:26:40 -08:00

1105 lines
37 KiB
TypeScript

import { useLoaderData, Link } from "react-router";
import { eq, and, asc, desc, inArray } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { useDraftSocket } from "~/hooks/useDraftSocket";
import { useEffect, useState, useMemo } from "react";
import { Card } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { getAuth } from "@clerk/react-router/server";
import { getTeamQueue } from "~/models/draft-queue";
import { DraftSidebar } from "~/components/DraftSidebar";
import { TeamsDraftedGrid } from "~/components/draft/TeamsDraftedGrid";
import { QueueSection } from "~/components/draft/QueueSection";
import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection";
import { SidebarRecentPicks } from "~/components/draft/SidebarRecentPicks";
import { DraftGridSection } from "~/components/draft/DraftGridSection";
import { ConnectionOverlay } from "~/components/draft/ConnectionOverlay";
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { toast } from "sonner";
export async function loader(args: any) {
const { params } = args;
const { seasonId, leagueId } = params;
const auth = await getAuth(args);
const userId = (auth as any).userId as string | null;
if (!seasonId) {
throw new Response("Season ID is required", { status: 400 });
}
if (!userId) {
throw new Response("You must be logged in to view the draft room", {
status: 401,
});
}
const db = database();
// Get season details
const season = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId),
with: {
league: true,
teams: true,
},
});
if (!season) {
throw new Response("Season not found", { status: 404 });
}
// Verify user access: must be team owner or commissioner
const userTeam = season.teams.find((team: any) => team.ownerId === userId);
const isCommissioner = await db.query.commissioners.findFirst({
where: and(
eq(schema.commissioners.leagueId, leagueId),
eq(schema.commissioners.userId, userId)
),
});
if (!userTeam && !isCommissioner) {
throw new Response("You do not have access to this draft room", {
status: 403,
});
}
// Get draft slots (draft order) - using select instead of query builder
const draftSlots = await db
.select({
id: schema.draftSlots.id,
draftOrder: schema.draftSlots.draftOrder,
team: schema.teams,
})
.from(schema.draftSlots)
.innerJoin(schema.teams, eq(schema.draftSlots.teamId, schema.teams.id))
.where(eq(schema.draftSlots.seasonId, seasonId))
.orderBy(asc(schema.draftSlots.draftOrder));
// Get all draft picks - using select instead of query builder
const draftPicks = await db
.select({
id: schema.draftPicks.id,
pickNumber: schema.draftPicks.pickNumber,
round: schema.draftPicks.round,
pickInRound: schema.draftPicks.pickInRound,
timeUsed: schema.draftPicks.timeUsed,
team: schema.teams,
participant: schema.participants,
sport: schema.sports,
})
.from(schema.draftPicks)
.innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id))
.innerJoin(
schema.participants,
eq(schema.draftPicks.participantId, schema.participants.id)
)
.innerJoin(
schema.sportsSeasons,
eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id)
)
.innerJoin(
schema.sports,
eq(schema.sportsSeasons.sportId, schema.sports.id)
)
.where(eq(schema.draftPicks.seasonId, seasonId))
.orderBy(asc(schema.draftPicks.pickNumber));
// Get sports seasons for this season
const seasonSports = await db.query.seasonSports.findMany({
where: eq(schema.seasonSports.seasonId, seasonId),
});
const sportsSeasonIds = seasonSports.map((ss) => ss.sportsSeasonId);
// Get ALL participants (both drafted and undrafted) - sorted by EV desc, then name
// Filtering will be done on the client side based on the "Show Drafted" toggle
let availableParticipants: any[] = [];
if (sportsSeasonIds.length > 0) {
availableParticipants = await db
.select({
id: schema.participants.id,
name: schema.participants.name,
expectedValue: schema.participants.expectedValue,
sport: schema.sports,
})
.from(schema.participants)
.innerJoin(
schema.sportsSeasons,
eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id)
)
.innerJoin(
schema.sports,
eq(schema.sportsSeasons.sportId, schema.sports.id)
)
.where(
sportsSeasonIds.length === 1
? eq(schema.participants.sportsSeasonId, sportsSeasonIds[0])
: inArray(schema.participants.sportsSeasonId, sportsSeasonIds)
)
.orderBy(
desc(schema.participants.expectedValue),
asc(schema.participants.name)
);
// Parse decimal expectedValue (Drizzle returns strings for decimal columns)
availableParticipants = availableParticipants.map((p: any) => ({
...p,
expectedValue: parseFloat(p.expectedValue) || 0,
}));
}
// Load user's team queue if they have a team
const userQueue = userTeam ? await getTeamQueue(userTeam.id) : [];
// Load draft timers for all teams
const timers = await db.query.draftTimers.findMany({
where: eq(schema.draftTimers.seasonId, seasonId),
});
// Load autodraft settings for all teams
const autodraftSettings = await db.query.autodraftSettings.findMany({
where: eq(schema.autodraftSettings.seasonId, seasonId),
});
// Load user's autodraft settings if they have a team
const userAutodraftSettings = userTeam
? await db.query.autodraftSettings.findFirst({
where: and(
eq(schema.autodraftSettings.seasonId, seasonId),
eq(schema.autodraftSettings.teamId, userTeam.id)
),
})
: null;
return {
season,
draftSlots,
draftPicks,
availableParticipants,
userTeam,
userQueue,
timers,
autodraftSettings,
userAutodraftSettings,
isCommissioner: !!isCommissioner,
currentUserId: userId,
};
}
export default function DraftRoom() {
const {
season,
draftSlots,
draftPicks,
availableParticipants,
userTeam,
userQueue,
timers,
autodraftSettings,
userAutodraftSettings,
isCommissioner,
} = useLoaderData<typeof loader>();
const { isConnected, connectionError, isReconnecting, on, off } = useDraftSocket(season.id, userTeam?.id);
const [picks, setPicks] = useState(draftPicks);
const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1);
const [searchQuery, setSearchQuery] = useState("");
const [hideDrafted, setHideDrafted] = useState(true);
const [hideIneligible, setHideIneligible] = useState(true);
const [sportFilter, setSportFilter] = useState<string>("all");
const [queue, setQueue] = useState(userQueue);
const [isPaused, setIsPaused] = useState(season.draftPaused || false);
const [isDraftComplete, setIsDraftComplete] = useState(season.status === "active");
// Sidebar and tabs state
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
if (typeof window === "undefined") return false;
const stored = localStorage.getItem("draftSidebarCollapsed");
return stored ? JSON.parse(stored) : false;
});
const [activeTab, setActiveTab] = useState<"participants" | "board" | "teams">("participants");
// Track autodraft status for all teams
const [autodraftStatus, setAutodraftStatus] = useState<Record<string, boolean>>(() => {
const status: Record<string, boolean> = {};
autodraftSettings.forEach((setting: any) => {
status[setting.teamId] = setting.isEnabled;
});
return status;
});
// Track connected teams
const [connectedTeams, setConnectedTeams] = useState<Set<string>>(new Set());
// Track user's autodraft settings
const [userAutodraft, setUserAutodraft] = useState({
isEnabled: userAutodraftSettings?.isEnabled || false,
mode: (userAutodraftSettings?.mode || "next_pick") as "next_pick" | "while_on",
});
const [teamTimers, setTeamTimers] = useState<Record<string, number>>(() => {
// Initialize with timers from loader data, or use initial time if draft hasn't started
const timersMap: Record<string, number> = {};
const initialTime = season.draftInitialTime || 120;
if (timers.length > 0) {
// Draft has started, use actual timers
timers.forEach((timer: any) => {
timersMap[timer.teamId] = timer.timeRemaining;
});
} else {
// Draft hasn't started, show initial time for all teams
draftSlots.forEach((slot) => {
timersMap[slot.team.id] = initialTime;
});
}
return timersMap;
});
const [forcePickDialogOpen, setForcePickDialogOpen] = useState(false);
const [selectedPickSlot, setSelectedPickSlot] = useState<{
pickNumber: number;
teamId: string;
} | null>(null);
// Calculate draft eligibility for current user's team
const eligibility = useMemo(() => {
if (!userTeam) return null;
// Transform picks data to match eligibility function signature
const transformedPicks = picks.map((p: any) => ({
teamId: p.team.id,
participant: {
id: p.participant.id,
sport: {
id: p.sport.id,
name: p.sport.name,
},
},
}));
const userTeamPicks = transformedPicks.filter(
(p: any) => p.teamId === userTeam.id
);
// Transform participants to match signature
const transformedParticipants = availableParticipants.map((p: any) => ({
id: p.id,
sport: {
id: p.sport.id,
name: p.sport.name,
},
}));
// Get unique sports from availableParticipants
const sportsMap = new Map();
availableParticipants.forEach((p: any) => {
if (!sportsMap.has(p.sport.id)) {
sportsMap.set(p.sport.id, { id: p.sport.id, name: p.sport.name });
}
});
const seasonSportsData = Array.from(sportsMap.values());
return calculateDraftEligibility(
userTeam.id,
userTeamPicks,
transformedPicks,
transformedParticipants,
seasonSportsData,
season.draftRounds,
season.teams
);
}, [userTeam, picks, availableParticipants, season]);
// Calculate eligibility for force manual pick dialog (selected team)
const forcePickEligibility = useMemo(() => {
if (!selectedPickSlot) return null;
const transformedPicks = picks.map((p: any) => ({
teamId: p.team.id,
participant: {
id: p.participant.id,
sport: {
id: p.sport.id,
name: p.sport.name,
},
},
}));
const selectedTeamPicks = transformedPicks.filter(
(p: any) => p.teamId === selectedPickSlot.teamId
);
const transformedParticipants = availableParticipants.map((p: any) => ({
id: p.id,
sport: {
id: p.sport.id,
name: p.sport.name,
},
}));
const sportsMap = new Map();
availableParticipants.forEach((p: any) => {
if (!sportsMap.has(p.sport.id)) {
sportsMap.set(p.sport.id, { id: p.sport.id, name: p.sport.name });
}
});
const seasonSportsData = Array.from(sportsMap.values());
return calculateDraftEligibility(
selectedPickSlot.teamId,
selectedTeamPicks,
transformedPicks,
transformedParticipants,
seasonSportsData,
season.draftRounds,
season.teams
);
}, [selectedPickSlot, picks, availableParticipants, season]);
// Listen for new picks from other users
useEffect(() => {
const handlePickMade = (data: any) => {
console.log("Pick made:", data);
setPicks((prev: any) => [...prev, data.pick]);
setCurrentPick(data.nextPickNumber);
};
const handleTimerUpdate = (data: any) => {
// Update the specific team's timer
setTeamTimers((prev) => ({
...prev,
[data.teamId]: data.timeRemaining,
}));
};
const handleDraftPaused = () => {
setIsPaused(true);
};
const handleDraftResumed = () => {
setIsPaused(false);
};
const handleDraftCompleted = () => {
setIsDraftComplete(true);
};
const handleAutodraftUpdated = (data: { teamId: string; isEnabled: boolean; mode: "next_pick" | "while_on" }) => {
console.log("Autodraft updated:", data);
setAutodraftStatus((prev) => ({
...prev,
[data.teamId]: data.isEnabled,
}));
// Update user's local state if it's their team
if (userTeam && data.teamId === userTeam.id) {
setUserAutodraft({
isEnabled: data.isEnabled,
mode: data.mode,
});
}
};
const handleTeamConnected = (data: { teamId: string }) => {
console.log("Team connected:", data.teamId);
setConnectedTeams((prev) => new Set(prev).add(data.teamId));
};
const handleTeamDisconnected = (data: { teamId: string }) => {
console.log("Team disconnected:", data.teamId);
setConnectedTeams((prev) => {
const newSet = new Set(prev);
newSet.delete(data.teamId);
return newSet;
});
};
const handleConnectedTeamsList = (data: { teamIds: string[] }) => {
console.log("Received connected teams list:", data.teamIds);
setConnectedTeams(new Set(data.teamIds));
};
const handleParticipantRemovedFromQueues = (data: { participantId: string }) => {
console.log("Participant removed from queues:", data.participantId);
setQueue((prev: any) =>
prev.filter((item: any) => item.participantId !== data.participantId)
);
};
on("pick-made", handlePickMade);
on("timer-update", handleTimerUpdate);
on("draft-paused", handleDraftPaused);
on("draft-resumed", handleDraftResumed);
on("draft-completed", handleDraftCompleted);
on("autodraft-updated", handleAutodraftUpdated);
on("team-connected", handleTeamConnected);
on("team-disconnected", handleTeamDisconnected);
on("connected-teams-list", handleConnectedTeamsList);
on("participant-removed-from-queues", handleParticipantRemovedFromQueues);
return () => {
off("pick-made", handlePickMade);
off("timer-update", handleTimerUpdate);
off("draft-paused", handleDraftPaused);
off("draft-resumed", handleDraftResumed);
off("draft-completed", handleDraftCompleted);
off("autodraft-updated", handleAutodraftUpdated);
off("team-connected", handleTeamConnected);
off("team-disconnected", handleTeamDisconnected);
off("connected-teams-list", handleConnectedTeamsList);
off("participant-removed-from-queues", handleParticipantRemovedFromQueues);
};
}, [on, off, currentPick, userTeam]);
// Persist sidebar collapsed state
useEffect(() => {
if (typeof window !== "undefined") {
localStorage.setItem("draftSidebarCollapsed", JSON.stringify(sidebarCollapsed));
}
}, [sidebarCollapsed]);
// Queue handlers
const handleAddToQueue = async (participantId: string) => {
if (!userTeam) return;
// Check if participant is already in queue (client-side)
const alreadyInQueue = queue.some((item: any) => item.participantId === participantId);
if (alreadyInQueue) {
toast.error("This participant is already in your queue");
return;
}
// Optimistic update - create a temporary queue item
const tempQueueItem = {
id: `temp-${Date.now()}`, // Temporary ID
participantId,
queuePosition: queue.length + 1,
};
// Immediately update the UI
setQueue((prev: any) => [...prev, tempQueueItem]);
const formData = new FormData();
formData.append("seasonId", season.id);
formData.append("teamId", userTeam.id);
formData.append("participantId", participantId);
try {
const response = await fetch("/api/queue/add", {
method: "POST",
body: formData,
});
if (response.ok) {
const data = await response.json();
// Replace temp item with real item from server
setQueue((prev: any) =>
prev.map((item: any) =>
item.id === tempQueueItem.id ? data.queueItem : item
)
);
} else {
// Revert the optimistic update on error
setQueue((prev: any) =>
prev.filter((item: any) => item.id !== tempQueueItem.id)
);
const error = await response.json();
toast.error(error.error || "Failed to add to queue");
}
} catch (error) {
// Revert the optimistic update on network error
setQueue((prev: any) =>
prev.filter((item: any) => item.id !== tempQueueItem.id)
);
toast.error("Network error - failed to add to queue");
}
};
const handleRemoveFromQueue = async (queueId: string) => {
if (!userTeam) return;
const formData = new FormData();
formData.append("queueId", queueId);
formData.append("teamId", userTeam.id);
const response = await fetch("/api/queue/remove", {
method: "POST",
body: formData,
});
if (response.ok) {
const data = await response.json();
setQueue(data.queue);
}
};
const handleReorderQueue = async (participantIds: string[]) => {
if (!userTeam) return;
const formData = new FormData();
formData.append("teamId", userTeam.id);
formData.append("seasonId", season.id);
formData.append("participantIds", JSON.stringify(participantIds));
const response = await fetch("/api/queue/reorder", {
method: "POST",
body: formData,
});
if (response.ok) {
const data = await response.json();
setQueue(data.queue);
}
};
const handleStartDraft = async () => {
const formData = new FormData();
formData.append("seasonId", season.id);
const response = await fetch("/api/draft/start", {
method: "POST",
body: formData,
});
if (response.ok) {
window.location.reload(); // Reload to get updated season status
}
};
const handlePauseDraft = async () => {
const formData = new FormData();
formData.append("seasonId", season.id);
const response = await fetch("/api/draft/pause", {
method: "POST",
body: formData,
});
if (response.ok) {
setIsPaused(true);
}
};
const handleResumeDraft = async () => {
const formData = new FormData();
formData.append("seasonId", season.id);
const response = await fetch("/api/draft/resume", {
method: "POST",
body: formData,
});
if (response.ok) {
setIsPaused(false);
}
};
const handleMakePick = async (participantId: string) => {
const formData = new FormData();
formData.append("seasonId", season.id);
formData.append("participantId", participantId);
const response = await fetch("/api/draft/make-pick", {
method: "POST",
body: formData,
});
if (response.ok) {
// Pick will be broadcast via socket, no need to manually update
} else {
const error = await response.json();
alert(error.error || "Failed to make pick");
}
};
const handleForceAutopick = async (pickNumber: number, teamId: string) => {
const formData = new FormData();
formData.append("seasonId", season.id);
formData.append("teamId", teamId);
formData.append("pickNumber", pickNumber.toString());
const response = await fetch("/api/draft/force-autopick", {
method: "POST",
body: formData,
});
if (response.ok) {
// Pick will be broadcast via socket
} else {
const error = await response.json();
alert(error.error || "Failed to force autopick");
}
};
const handleForceManualPick = async (participantId: string) => {
if (!selectedPickSlot) return;
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 fetch("/api/draft/force-manual-pick", {
method: "POST",
body: formData,
});
if (response.ok) {
setForcePickDialogOpen(false);
setSelectedPickSlot(null);
} else {
const error = await response.json();
alert(error.error || "Failed to force manual pick");
}
};
// Calculate current round
const currentRound = Math.ceil(currentPick / draftSlots.length);
// Determine whose turn it is
const totalTeams = draftSlots.length;
const isSnakeDraft = true;
const isEvenRound = currentRound % 2 === 0;
let pickInRound = ((currentPick - 1) % totalTeams) + 1;
if (isSnakeDraft && isEvenRound) {
pickInRound = totalTeams - pickInRound + 1;
}
const currentDraftSlot = draftSlots.find(
(slot) => slot.draftOrder === pickInRound
);
const isMyTurn = !!(
userTeam && currentDraftSlot && currentDraftSlot.team.id === userTeam.id
);
const canPick = isMyTurn && season.status === "draft"; // Only team owner on their turn
// Generate snake draft grid structure
const generateDraftGrid = () => {
const teamCount = draftSlots.length;
const rounds = season.draftRounds;
const grid: Array<
Array<{
pickNumber: number;
round: number;
pickInRound: number;
teamId: string;
pick?: any;
}>
> = [];
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;
// Find if this pick has been made
const pick = picks.find((p: any) => p.pickNumber === pickNumber);
roundPicks.push({
pickNumber,
round,
pickInRound,
teamId,
pick,
});
}
grid.push(roundPicks);
}
return grid;
};
const draftGrid = generateDraftGrid();
// Get drafted participant IDs for filtering
const draftedParticipantIds = new Set(
picks.map((p: any) => p.participant.id)
);
// Get unique sports for filter dropdown
const uniqueSports = Array.from(
new Set(availableParticipants.map((p: any) => p.sport.name))
).sort();
// Filter participants based on search, sport, drafted status, and eligibility
const filteredParticipants = availableParticipants.filter(
(participant: any) => {
// 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;
}
}
// Search filter
if (
searchQuery &&
!participant.name.toLowerCase().includes(searchQuery.toLowerCase())
) {
return false;
}
// Sport filter
if (sportFilter !== "all" && participant.sport.name !== sportFilter) {
return false;
}
return true;
}
);
return (
<div className="h-screen bg-background flex flex-col overflow-hidden">
{/* Draft Completion Banner */}
{isDraftComplete && (
<div className="bg-green-500 text-white px-4 py-3 text-center font-semibold flex-shrink-0">
🎉 Draft Complete! The season is now active.
{season.league.isPublicDraftBoard && (
<>
{" "}
<a
href={`/leagues/${season.leagueId}/draft-board/${season.id}`}
className="underline hover:text-green-100"
>
View Draft Board
</a>
</>
)}
</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>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</Button>
)}
<div>
<h1 className="text-3xl font-bold">
{season.league.name} - {season.year} Draft
</h1>
<div className="flex gap-4 text-sm text-muted-foreground mt-1">
<span>Round: {currentRound}</span>
<span>Pick: {currentPick}</span>
<span className="capitalize">
{season.status.replace("_", " ")}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-3">
{/* Commissioner Controls */}
{isCommissioner && season.status === "pre_draft" && (
<Button onClick={handleStartDraft}>Start Draft</Button>
)}
{isCommissioner && season.status === "draft" && (
<>
{isPaused ? (
<Button onClick={handleResumeDraft} variant="default">
Resume Draft
</Button>
) : (
<Button onClick={handlePauseDraft} variant="outline">
Pause Draft
</Button>
)}
</>
)}
<div className="flex items-center gap-2">
<div
className={`w-3 h-3 rounded-full ${
isConnected ? "bg-green-500" : "bg-red-500"
}`}
/>
<span className="text-sm font-medium">
{isConnected ? "Live" : "Disconnected"}
</span>
</div>
<Button variant="outline" asChild>
<Link to={`/leagues/${season.leagueId}`}>Exit Draft Room</Link>
</Button>
</div>
</div>
</div>
</div>
{/* Main Content - Flex Row: Sidebar + Tabs */}
<div className="flex-1 flex overflow-hidden">
{/* Sidebar */}
{userTeam && (
<DraftSidebar
collapsed={sidebarCollapsed}
onCollapsedChange={setSidebarCollapsed}
queueSection={
<QueueSection
queue={queue}
availableParticipants={availableParticipants}
eligibility={eligibility}
seasonId={season.id}
teamId={userTeam.id}
isMyTurn={isMyTurn}
userAutodraft={userAutodraft}
onRemoveFromQueue={handleRemoveFromQueue}
onAutodraftUpdate={(isEnabled, mode) => {
setUserAutodraft({ isEnabled, mode });
}}
onReorder={handleReorderQueue}
/>
}
recentPicksSection={
<SidebarRecentPicks picks={picks} />
}
/>
)}
{/* Main Tabbed Content */}
<div className="flex-1 overflow-hidden">
<Tabs
value={activeTab}
onValueChange={(value) =>
setActiveTab(value as "participants" | "board" | "teams")
}
className="h-full flex flex-col"
>
<TabsList className="mx-4 mt-4">
<TabsTrigger value="participants">Available Participants</TabsTrigger>
<TabsTrigger value="board">Draft Board</TabsTrigger>
<TabsTrigger value="teams">Teams Drafted</TabsTrigger>
</TabsList>
<TabsContent value="participants" className="flex-1 overflow-hidden m-0">
<div className="h-full">
<AvailableParticipantsSection
participants={filteredParticipants}
searchQuery={searchQuery}
sportFilter={sportFilter}
hideDrafted={hideDrafted}
hideIneligible={hideIneligible}
uniqueSports={uniqueSports}
draftedParticipantIds={draftedParticipantIds}
queue={queue}
eligibility={eligibility}
canPick={canPick}
hasTeam={!!userTeam}
onSearchChange={setSearchQuery}
onSportFilterChange={setSportFilter}
onHideDraftedChange={setHideDrafted}
onHideIneligibleChange={setHideIneligible}
onMakePick={handleMakePick}
onAddToQueue={handleAddToQueue}
onRemoveFromQueue={handleRemoveFromQueue}
/>
</div>
</TabsContent>
<TabsContent value="board" className="flex-1 overflow-hidden m-0">
<DraftGridSection
draftSlots={draftSlots}
draftGrid={draftGrid}
currentPick={currentPick}
teamTimers={teamTimers}
autodraftStatus={autodraftStatus}
connectedTeams={connectedTeams}
isCommissioner={isCommissioner}
onForceAutopick={handleForceAutopick}
onForceManualPickOpen={(pickNumber, teamId) => {
setSelectedPickSlot({ pickNumber, teamId });
setForcePickDialogOpen(true);
}}
/>
</TabsContent>
<TabsContent value="teams" className="flex-1 overflow-hidden m-0">
<div className="h-full">
<TeamsDraftedGrid
draftSlots={draftSlots}
picks={picks}
availableParticipants={availableParticipants}
season={{
id: season.id,
name: `${season.year} Season`,
year: season.year,
numFlexPicks: season.flexSpots,
}}
/>
</div>
</TabsContent>
</Tabs>
</div>
</div>
{/* Force Manual Pick Dialog */}
{forcePickDialogOpen && selectedPickSlot && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={() => setForcePickDialogOpen(false)}
>
<Card
className="w-full max-w-2xl max-h-[80vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6">
<h2 className="text-2xl font-bold mb-4">Force Manual Pick</h2>
<p className="text-sm text-muted-foreground mb-4">
Select a participant to draft for Pick #
{selectedPickSlot.pickNumber}
</p>
{/* Search and Filter */}
<div className="flex gap-2 mb-4">
<input
type="text"
placeholder="Search participants..."
className="flex-1 px-3 py-2 border rounded-md"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<select
className="px-3 py-2 border rounded-md"
value={sportFilter}
onChange={(e) => setSportFilter(e.target.value)}
>
<option value="all">All Sports</option>
{uniqueSports.map((sport) => (
<option key={sport} value={sport}>
{sport}
</option>
))}
</select>
</div>
{/* Participant List */}
<div className="max-h-96 overflow-y-auto border rounded-lg">
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0">
<tr>
<th className="text-left p-3 font-semibold">
Participant
</th>
<th className="text-left p-3 font-semibold">Sport</th>
<th className="text-right p-3 font-semibold">EV</th>
<th className="text-right p-3 font-semibold">Action</th>
</tr>
</thead>
<tbody>
{filteredParticipants
.filter((p: any) => !draftedParticipantIds.has(p.id))
.map((participant: any) => {
// Check eligibility for force pick team
const isEligible = forcePickEligibility
? forcePickEligibility.eligibleSportIds.has(participant.sport.id)
: true;
const ineligibleReason = forcePickEligibility?.ineligibleReasons[participant.sport.id];
return (
<tr
key={participant.id}
className={`border-t ${
isEligible
? "hover:bg-muted/50"
: "bg-red-50/30 dark:bg-red-950/20 opacity-60"
}`}
title={!isEligible ? ineligibleReason : undefined}
>
<td className="p-3">
<div className="flex items-center gap-2">
<span className={`font-medium ${!isEligible ? "text-muted-foreground" : ""}`}>
{participant.name}
</span>
{!isEligible && (
<Badge
variant="destructive"
className="text-xs"
title={ineligibleReason}
>
Ineligible
</Badge>
)}
</div>
</td>
<td className="p-3 text-muted-foreground">
{participant.sport.name}
</td>
<td className="p-3 text-right font-mono font-semibold">
{participant.expectedValue ? participant.expectedValue.toFixed(1) : "—"}
</td>
<td className="p-3 text-right">
<Button
size="sm"
onClick={() =>
handleForceManualPick(participant.id)
}
disabled={!isEligible}
title={!isEligible ? ineligibleReason : undefined}
>
Draft
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setForcePickDialogOpen(false)}
>
Cancel
</Button>
</div>
</div>
</Card>
</div>
)}
{/* Connection Overlay - blocks interaction until socket connects */}
<ConnectionOverlay
isConnected={isConnected}
isReconnecting={isReconnecting}
connectionError={connectionError}
/>
</div>
);
}