feat: implement real-time draft room with commissioner controls and socket events

This commit is contained in:
Chris Parsons 2025-10-16 01:52:17 -07:00
parent c820dfbdad
commit d93812315d
9 changed files with 786 additions and 12 deletions

View file

@ -0,0 +1,107 @@
import { Button } from "~/components/ui/button";
import { Play, Pause, FastForward } from "lucide-react";
interface CommissionerControlsProps {
isDraftStarted: boolean;
isDraftPaused: boolean;
isDraftComplete: boolean;
currentTeamName: string;
onStartDraft: () => void;
onPauseDraft: () => void;
onResumeDraft: () => void;
onForcePick: () => void;
}
export function CommissionerControls({
isDraftStarted,
isDraftPaused,
isDraftComplete,
currentTeamName,
onStartDraft,
onPauseDraft,
onResumeDraft,
onForcePick,
}: CommissionerControlsProps) {
if (isDraftComplete) {
return (
<div className="space-y-4">
<div className="rounded-lg bg-green-500/10 p-4 text-center">
<p className="font-medium text-green-600 dark:text-green-400">
Draft Complete
</p>
</div>
</div>
);
}
if (!isDraftStarted) {
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
The draft has not started yet. Click below to begin the draft.
</p>
<Button onClick={onStartDraft} className="w-full" size="lg">
<Play className="mr-2 h-4 w-4" />
Start Draft
</Button>
</div>
);
}
return (
<div className="space-y-4">
{/* Draft Status */}
<div className="rounded-lg border bg-muted/50 p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Draft Status:</span>
<span
className={`text-sm font-semibold ${
isDraftPaused
? "text-yellow-600 dark:text-yellow-400"
: "text-green-600 dark:text-green-400"
}`}
>
{isDraftPaused ? "Paused" : "Active"}
</span>
</div>
<div className="mt-2 flex items-center justify-between">
<span className="text-sm font-medium">Current Pick:</span>
<span className="text-sm text-muted-foreground">{currentTeamName}</span>
</div>
</div>
{/* Control Buttons */}
<div className="space-y-2">
{isDraftPaused ? (
<Button onClick={onResumeDraft} className="w-full" variant="default">
<Play className="mr-2 h-4 w-4" />
Resume Draft
</Button>
) : (
<Button onClick={onPauseDraft} className="w-full" variant="outline">
<Pause className="mr-2 h-4 w-4" />
Pause Draft
</Button>
)}
<Button
onClick={onForcePick}
className="w-full"
variant="destructive"
disabled={isDraftPaused}
>
<FastForward className="mr-2 h-4 w-4" />
Force Pick for {currentTeamName}
</Button>
</div>
{/* Help Text */}
<div className="rounded-lg bg-muted/30 p-3">
<p className="text-xs text-muted-foreground">
<strong>Force Pick:</strong> Automatically selects the top player from the
current team's queue, or the highest EV available player if queue is empty.
</p>
</div>
</div>
);
}

View file

@ -9,6 +9,7 @@ export default [
route("leagues/:leagueId/draft", "routes/leagues/$leagueId.draft.tsx"),
route("teams/:teamId/settings", "routes/teams/$teamId.settings.tsx"),
route("api/webhooks/clerk", "routes/api/webhooks/clerk.ts"),
route("api/draft-actions", "routes/api/draft-actions.ts"),
route("user-profile", "routes/user-profile.tsx"),
route("how-to-play", "routes/how-to-play.tsx"),

View file

@ -0,0 +1,95 @@
import { data, type ActionFunctionArgs } from "react-router";
import { getAuth } from "@clerk/react-router/server";
import {
addToQueue,
removeFromQueue,
reorderQueueWithSeason,
findTeamById,
getTeamQueue,
} from "~/models";
export async function action(args: ActionFunctionArgs) {
const { userId } = await getAuth(args);
if (!userId) {
return data({ error: "Unauthorized" }, { status: 401 });
}
const { request } = args;
const formData = await request.formData();
const actionType = formData.get("action");
const teamId = formData.get("teamId");
if (!teamId || typeof teamId !== "string") {
return data({ error: "Team ID is required" }, { status: 400 });
}
// Verify user owns this team
const team = await findTeamById(teamId);
if (!team || team.ownerId !== userId) {
return data({ error: "You do not own this team" }, { status: 403 });
}
try {
switch (actionType) {
case "add-to-queue": {
const participantId = formData.get("participantId");
const seasonId = formData.get("seasonId");
if (!participantId || typeof participantId !== "string") {
return data({ error: "Participant ID is required" }, { status: 400 });
}
if (!seasonId || typeof seasonId !== "string") {
return data({ error: "Season ID is required" }, { status: 400 });
}
// Get current queue to determine position
const currentQueue = await getTeamQueue(teamId);
const queuePosition = currentQueue.length + 1;
await addToQueue({
seasonId,
teamId,
participantId,
queuePosition,
});
return data({ success: true });
}
case "remove-from-queue": {
const queueItemId = formData.get("queueItemId");
if (!queueItemId || typeof queueItemId !== "string") {
return data({ error: "Queue item ID is required" }, { status: 400 });
}
await removeFromQueue(queueItemId);
return data({ success: true });
}
case "reorder-queue": {
const participantIds = formData.get("participantIds");
const seasonId = formData.get("seasonId");
if (!participantIds || typeof participantIds !== "string") {
return data({ error: "Participant IDs are required" }, { status: 400 });
}
if (!seasonId || typeof seasonId !== "string") {
return data({ error: "Season ID is required" }, { status: 400 });
}
const idsArray = JSON.parse(participantIds);
await reorderQueueWithSeason(seasonId, teamId, idsArray);
return data({ success: true });
}
default:
return data({ error: "Invalid action" }, { status: 400 });
}
} catch (error) {
console.error("Draft action error:", error);
return data({ error: "Failed to perform action" }, { status: 500 });
}
}

View file

@ -0,0 +1,203 @@
import { data, type ActionFunctionArgs } from "react-router";
import { getAuth } from "@clerk/react-router/server";
import {
findSeasonById,
updateSeason,
updateSeasonStatus,
findTeamsBySeasonId,
isCommissioner,
createDraftPick,
initializeDraftTimers,
updateTeamTimer,
getSeasonTimers,
autoPickForTeam,
calculatePickInfo,
removeFromQueue,
getTeamQueue,
} from "~/models";
import { getSocketIO } from "../../../server/socket.js";
export async function action(args: ActionFunctionArgs) {
const { userId } = await getAuth(args);
if (!userId) {
return data({ error: "Unauthorized" }, { status: 401 });
}
const { request } = args;
const formData = await request.formData();
const actionType = formData.get("action");
const seasonId = formData.get("seasonId");
if (!seasonId || typeof seasonId !== "string") {
return data({ error: "Season ID is required" }, { status: 400 });
}
// Get season
const season = await findSeasonById(seasonId);
if (!season) {
return data({ error: "Season not found" }, { status: 404 });
}
// Verify user is commissioner
const isUserCommissioner = await isCommissioner(season.leagueId, userId);
if (!isUserCommissioner) {
return data({ error: "Only commissioners can perform this action" }, { status: 403 });
}
try {
const io = getSocketIO();
switch (actionType) {
case "start-draft": {
// Check if draft already started
if (season.draftStartedAt) {
return data({ error: "Draft has already started" }, { status: 400 });
}
// Get teams
const teams = await findTeamsBySeasonId(seasonId);
// Initialize timers
await initializeDraftTimers(seasonId, teams, season.draftInitialTime || 120);
// Update season
await updateSeason(seasonId, {
draftStartedAt: new Date(),
currentPickNumber: 1,
draftPaused: false,
});
await updateSeasonStatus(seasonId, "draft");
// Get updated timers
const timers = await getSeasonTimers(seasonId);
// Broadcast to all clients
io.to(`draft-${seasonId}`).emit("draft-started", {
seasonId,
startedAt: new Date(),
timers,
});
return data({ success: true });
}
case "pause-draft": {
const pause = formData.get("pause") === "true";
// Check if draft has started
if (!season.draftStartedAt) {
return data({ error: "Draft has not started yet" }, { status: 400 });
}
// Update season
await updateSeason(seasonId, { draftPaused: pause });
// Broadcast to all clients
io.to(`draft-${seasonId}`).emit("draft-paused", {
seasonId,
isPaused: pause,
});
return data({ success: true });
}
case "force-pick": {
const teamId = formData.get("teamId");
if (!teamId || typeof teamId !== "string") {
return data({ error: "Team ID is required" }, { status: 400 });
}
// Check if draft has started
if (!season.draftStartedAt) {
return data({ error: "Draft has not started" }, { status: 400 });
}
// Check if draft is complete
const teams = await findTeamsBySeasonId(seasonId);
const totalPicks = season.draftRounds * teams.length;
const currentPickNumber = season.currentPickNumber || 1;
if (currentPickNumber > totalPicks) {
return data({ error: "Draft is already complete" }, { status: 400 });
}
// Auto-pick for the team
const participantId = await autoPickForTeam(seasonId, teamId);
if (!participantId) {
return data({ error: "No available participants to pick" }, { status: 400 });
}
// Calculate pick info
const { round, pickInRound } = calculatePickInfo(currentPickNumber, teams.length);
// Create the draft pick
const pick = await createDraftPick({
seasonId,
teamId,
participantId,
pickNumber: currentPickNumber,
round,
pickInRound,
pickedByUserId: userId,
pickedByType: "commissioner",
timeUsed: 0,
});
// Remove from queue if it was queued
const queue = await getTeamQueue(teamId);
const queueItem = queue.find((item: any) => item.participantId === participantId);
if (queueItem) {
await removeFromQueue(queueItem.id);
}
// Update season to next pick
const nextPickNumber = currentPickNumber + 1;
await updateSeason(seasonId, { currentPickNumber: nextPickNumber });
// Add increment time to current team's timer
if (season.draftIncrementTime) {
const timers = await getSeasonTimers(seasonId);
const currentTimer = timers.find((t: any) => t.teamId === teamId);
if (currentTimer) {
const newTime = currentTimer.timeRemaining + season.draftIncrementTime;
await updateTeamTimer(teamId, newTime);
}
}
// Check if draft is complete
if (nextPickNumber > totalPicks) {
await updateSeasonStatus(seasonId, "active");
io.to(`draft-${seasonId}`).emit("draft-completed", {
seasonId,
});
}
// Broadcast pick to all clients in the room
io.to(`draft-${seasonId}`).emit("pick-made", {
pick,
nextPickNumber,
isDraftComplete: nextPickNumber > totalPicks,
});
// Emit queue update to team owner
const updatedQueue = await getTeamQueue(teamId);
io.to(`draft-${seasonId}`).emit("queue-updated", {
teamId,
queue: updatedQueue,
});
return data({ success: true, pick });
}
default:
return data({ error: "Invalid action" }, { status: 400 });
}
} catch (error) {
console.error("Commissioner action error:", error);
return data({
error: error instanceof Error ? error.message : "Failed to perform action"
}, { status: 500 });
}
}

View file

@ -0,0 +1,175 @@
import { data, type ActionFunctionArgs } from "react-router";
import { getAuth } from "@clerk/react-router/server";
import {
findSeasonById,
updateSeason,
updateSeasonStatus,
findTeamsBySeasonId,
isCommissioner,
createDraftPick,
isParticipantDrafted,
updateTeamTimer,
getSeasonTimers,
calculatePickInfo,
getTeamForPick,
findDraftSlotsBySeasonId,
removeFromQueue,
getTeamQueue,
} from "~/models";
import { getSocketIO } from "../../../server/socket.js";
export async function action(args: ActionFunctionArgs) {
const { userId } = await getAuth(args);
if (!userId) {
return data({ error: "Unauthorized" }, { status: 401 });
}
const { request } = args;
const formData = await request.formData();
const seasonId = formData.get("seasonId");
const teamId = formData.get("teamId");
const participantId = formData.get("participantId");
if (!seasonId || typeof seasonId !== "string") {
return data({ error: "Season ID is required" }, { status: 400 });
}
if (!teamId || typeof teamId !== "string") {
return data({ error: "Team ID is required" }, { status: 400 });
}
if (!participantId || typeof participantId !== "string") {
return data({ error: "Participant ID is required" }, { status: 400 });
}
try {
// Get season
const season = await findSeasonById(seasonId);
if (!season) {
return data({ error: "Season not found" }, { status: 404 });
}
// Check if draft has started
if (!season.draftStartedAt) {
return data({ error: "Draft has not started" }, { status: 400 });
}
// Check if draft is paused
if (season.draftPaused) {
return data({ error: "Draft is currently paused" }, { status: 400 });
}
// Check if draft is complete
const teams = await findTeamsBySeasonId(seasonId);
const totalPicks = season.draftRounds * teams.length;
const currentPickNumber = season.currentPickNumber || 1;
if (currentPickNumber > totalPicks) {
return data({ error: "Draft is already complete" }, { status: 400 });
}
// Get draft slots to determine current team
const draftSlots = await findDraftSlotsBySeasonId(seasonId);
const draftOrder = draftSlots.map((slot: any) => ({
teamId: slot.teamId,
draftOrder: slot.draftOrder,
}));
const currentTeamId = getTeamForPick(currentPickNumber, draftOrder);
if (!currentTeamId) {
return data({ error: "Could not determine current team" }, { status: 400 });
}
// Verify it's the correct team's turn
if (teamId !== currentTeamId) {
return data({ error: "It is not this team's turn" }, { status: 400 });
}
// Verify user has permission (team owner or commissioner)
const team = teams.find((t: any) => t.id === teamId);
const isTeamOwner = team?.ownerId === userId;
const isUserCommissioner = await isCommissioner(season.leagueId, userId);
if (!isTeamOwner && !isUserCommissioner) {
return data({ error: "You do not have permission to make this pick" }, { status: 403 });
}
// Check if participant is already drafted
const isDrafted = await isParticipantDrafted(seasonId, participantId);
if (isDrafted) {
return data({ error: "This participant has already been drafted" }, { status: 400 });
}
// Calculate pick info
const { round, pickInRound } = calculatePickInfo(currentPickNumber, teams.length);
// Determine picked by type
const pickedByType = isUserCommissioner && !isTeamOwner ? "commissioner" : "owner";
// Create the draft pick
const pick = await createDraftPick({
seasonId,
teamId,
participantId,
pickNumber: currentPickNumber,
round,
pickInRound,
pickedByUserId: userId,
pickedByType,
timeUsed: 0,
});
// Remove from queue if it was queued
const queue = await getTeamQueue(teamId);
const queueItem = queue.find((item: any) => item.participantId === participantId);
if (queueItem) {
await removeFromQueue(queueItem.id);
}
// Update season to next pick
const nextPickNumber = currentPickNumber + 1;
await updateSeason(seasonId, { currentPickNumber: nextPickNumber });
// Add increment time to current team's timer
if (season.draftIncrementTime) {
const timers = await getSeasonTimers(seasonId);
const currentTimer = timers.find((t: any) => t.teamId === teamId);
if (currentTimer) {
const newTime = currentTimer.timeRemaining + season.draftIncrementTime;
await updateTeamTimer(teamId, newTime);
}
}
// Check if draft is complete
const io = getSocketIO();
if (nextPickNumber > totalPicks) {
await updateSeasonStatus(seasonId, "active");
io.to(`draft-${seasonId}`).emit("draft-completed", {
seasonId,
});
}
// Broadcast pick to all clients in the room
io.to(`draft-${seasonId}`).emit("pick-made", {
pick,
nextPickNumber,
isDraftComplete: nextPickNumber > totalPicks,
});
// Emit queue update to team owner
const updatedQueue = await getTeamQueue(teamId);
io.to(`draft-${seasonId}`).emit("queue-updated", {
teamId,
queue: updatedQueue,
});
return data({ success: true, pick });
} catch (error) {
console.error("Error making pick:", error);
return data({
error: error instanceof Error ? error.message : "Failed to make pick",
}, { status: 500 });
}
}

View file

@ -1,6 +1,7 @@
import { data, redirect } from "react-router";
import type { LoaderFunctionArgs } from "react-router";
import { getAuth } from "@clerk/react-router/server";
import { useEffect, useState } from "react";
import {
findLeagueById,
findCurrentSeason,
@ -11,10 +12,13 @@ import {
getSeasonTimers,
findSeasonWithSportsSeasons,
getTeamQueue,
getTeamForPick,
} from "~/models";
import { DraftGrid } from "~/components/draft/DraftGrid";
import { PlayerList } from "~/components/draft/PlayerList";
import { DraftQueue } from "~/components/draft/DraftQueue";
import { CommissionerControls } from "~/components/draft/CommissionerControls";
import { useDraftSocket } from "~/hooks/useDraftSocket";
export async function loader(args: LoaderFunctionArgs) {
const { userId } = await getAuth(args);
@ -114,17 +118,173 @@ export default function DraftRoom({ loaderData }: { loaderData: Awaited<ReturnTy
league,
season,
teams,
draftPicks,
draftPicks: initialDraftPicks,
participants,
queueItems,
timers,
queueItems: initialQueueItems,
timers: initialTimers,
isCommissioner,
userTeam,
currentPickNumber,
currentPickNumber: initialPickNumber,
totalPicks,
isDraftComplete,
isDraftComplete: initialIsDraftComplete,
draftSlots,
} = loaderData;
// Local state for real-time updates
const [draftPicks, setDraftPicks] = useState(initialDraftPicks);
const [queueItems, setQueueItems] = useState(initialQueueItems);
const [timers, setTimers] = useState(initialTimers);
const [currentPickNumber, setCurrentPickNumber] = useState(initialPickNumber);
const [isDraftComplete, setIsDraftComplete] = useState(initialIsDraftComplete);
const [isDraftStarted, setIsDraftStarted] = useState(!!season.draftStartedAt);
const [isDraftPaused, setIsDraftPaused] = useState(season.draftPaused || false);
// Socket connection
const { on, off } = useDraftSocket(season.id);
// Calculate current team
const draftOrder = draftSlots.map((slot: any) => ({
teamId: slot.teamId,
draftOrder: slot.draftOrder,
}));
const currentTeamId = getTeamForPick(currentPickNumber, draftOrder);
const currentTeam = teams.find((t: any) => t.id === currentTeamId);
const currentTeamName = currentTeam?.name || "Unknown";
// Socket event handlers
useEffect(() => {
// Listen for pick made
const handlePickMade = (data: any) => {
setDraftPicks((prev: any) => [...prev, data.pick]);
setCurrentPickNumber(data.nextPickNumber);
setIsDraftComplete(data.isDraftComplete);
};
// Listen for draft started
const handleDraftStarted = (data: any) => {
setIsDraftStarted(true);
setIsDraftPaused(false);
setTimers(data.timers);
};
// Listen for draft paused
const handleDraftPaused = (data: any) => {
setIsDraftPaused(data.isPaused);
};
// Listen for draft completed
const handleDraftCompleted = () => {
setIsDraftComplete(true);
};
// Listen for queue updates
const handleQueueUpdated = (data: any) => {
if (userTeam && data.teamId === userTeam.id) {
setQueueItems(data.queue);
}
};
on("pick-made", handlePickMade);
on("draft-started", handleDraftStarted);
on("draft-paused", handleDraftPaused);
on("draft-completed", handleDraftCompleted);
on("queue-updated", handleQueueUpdated);
return () => {
off("pick-made", handlePickMade);
off("draft-started", handleDraftStarted);
off("draft-paused", handleDraftPaused);
off("draft-completed", handleDraftCompleted);
off("queue-updated", handleQueueUpdated);
};
}, [on, off, userTeam]);
// Commissioner action handlers
const handleStartDraft = async () => {
try {
const formData = new FormData();
formData.append("action", "start-draft");
formData.append("seasonId", season.id);
const response = await fetch("/api/draft-commissioner", {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = await response.json();
console.error("Failed to start draft:", error);
}
} catch (error) {
console.error("Error starting draft:", error);
}
};
const handlePauseDraft = async () => {
try {
const formData = new FormData();
formData.append("action", "pause-draft");
formData.append("seasonId", season.id);
formData.append("pause", "true");
const response = await fetch("/api/draft-commissioner", {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = await response.json();
console.error("Failed to pause draft:", error);
}
} catch (error) {
console.error("Error pausing draft:", error);
}
};
const handleResumeDraft = async () => {
try {
const formData = new FormData();
formData.append("action", "pause-draft");
formData.append("seasonId", season.id);
formData.append("pause", "false");
const response = await fetch("/api/draft-commissioner", {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = await response.json();
console.error("Failed to resume draft:", error);
}
} catch (error) {
console.error("Error resuming draft:", error);
}
};
const handleForcePick = async () => {
if (!currentTeamId) return;
try {
const formData = new FormData();
formData.append("action", "force-pick");
formData.append("seasonId", season.id);
formData.append("teamId", currentTeamId);
const response = await fetch("/api/draft-commissioner", {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = await response.json();
console.error("Failed to force pick:", error);
}
} catch (error) {
console.error("Error forcing pick:", error);
}
};
return (
<div className="min-h-screen bg-background">
{/* Header */}
@ -213,9 +373,16 @@ export default function DraftRoom({ loaderData }: { loaderData: Awaited<ReturnTy
{isCommissioner ? (
<div className="rounded-lg border bg-card p-6">
<h2 className="mb-4 text-xl font-bold">Commissioner Controls</h2>
<div className="flex items-center justify-center py-12 text-muted-foreground">
Controls will be implemented in Phase 9
</div>
<CommissionerControls
isDraftStarted={isDraftStarted}
isDraftPaused={isDraftPaused}
isDraftComplete={isDraftComplete}
currentTeamName={currentTeamName}
onStartDraft={handleStartDraft}
onPauseDraft={handlePauseDraft}
onResumeDraft={handleResumeDraft}
onForcePick={handleForcePick}
/>
</div>
) : (
<div className="rounded-lg border bg-card p-6">
@ -229,6 +396,10 @@ export default function DraftRoom({ loaderData }: { loaderData: Awaited<ReturnTy
<span className="text-muted-foreground">Draft Rounds:</span>
<span className="ml-2 font-medium">{season.draftRounds}</span>
</div>
<div>
<span className="text-muted-foreground">Current Team:</span>
<span className="ml-2 font-medium">{currentTeamName}</span>
</div>
</div>
</div>
)}

View file

@ -32,13 +32,29 @@ export function setupSocketIO(httpServer: HTTPServer): SocketServer {
console.log(`Socket ${socket.id} left draft room: ${roomName}`);
});
// Draft Actions - Phase 9
// These events will be handled by API routes and then broadcast back via socket
// The client will call API routes which will then use getSocketIO() to broadcast
socket.on("make-pick", (_data) => {
console.log("make-pick event received, should use API route instead");
});
socket.on("commissioner-start-draft", (_data) => {
console.log("commissioner-start-draft event received, should use API route instead");
});
socket.on("commissioner-pause-draft", (_data) => {
console.log("commissioner-pause-draft event received, should use API route instead");
});
socket.on("commissioner-force-pick", (_data) => {
console.log("commissioner-force-pick event received, should use API route instead");
});
// Handle disconnection
socket.on("disconnect", () => {
console.log(`Client disconnected: ${socket.id}`);
});
// Draft-specific events will be added in Phase 9
// For now, we're just setting up the infrastructure
});
return io;

View file

@ -4,7 +4,8 @@
"server/**/*.ts",
"vite.config.ts",
"database/**/*.ts",
"app/contexts/**/*.ts"
"app/contexts/**/*.ts",
"app/models/**/*.ts"
],
"compilerOptions": {
"composite": true,

View file

@ -15,4 +15,9 @@ export default defineConfig(({ isSsrBuild }) => ({
server: {
allowedHosts: true,
},
resolve: {
alias: {
"~/database": "/database",
},
},
}));