* 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>
1105 lines
37 KiB
TypeScript
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>
|
|
);
|
|
}
|