From d93812315d1fe081f79ee28d69ad540891be5019 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Thu, 16 Oct 2025 01:52:17 -0700 Subject: [PATCH] feat: implement real-time draft room with commissioner controls and socket events --- app/components/draft/CommissionerControls.tsx | 107 +++++++++ app/routes.ts | 1 + app/routes/api/draft-actions.ts | 95 ++++++++ app/routes/api/draft-commissioner.ts | 203 ++++++++++++++++++ app/routes/api/draft-pick.ts | 175 +++++++++++++++ app/routes/leagues/$leagueId.draft.tsx | 187 +++++++++++++++- server/socket.ts | 22 +- tsconfig.node.json | 3 +- vite.config.ts | 5 + 9 files changed, 786 insertions(+), 12 deletions(-) create mode 100644 app/components/draft/CommissionerControls.tsx create mode 100644 app/routes/api/draft-actions.ts create mode 100644 app/routes/api/draft-commissioner.ts create mode 100644 app/routes/api/draft-pick.ts diff --git a/app/components/draft/CommissionerControls.tsx b/app/components/draft/CommissionerControls.tsx new file mode 100644 index 0000000..d00f7c9 --- /dev/null +++ b/app/components/draft/CommissionerControls.tsx @@ -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 ( +
+
+

+ Draft Complete +

+
+
+ ); + } + + if (!isDraftStarted) { + return ( +
+

+ The draft has not started yet. Click below to begin the draft. +

+ +
+ ); + } + + return ( +
+ {/* Draft Status */} +
+
+ Draft Status: + + {isDraftPaused ? "Paused" : "Active"} + +
+
+ Current Pick: + {currentTeamName} +
+
+ + {/* Control Buttons */} +
+ {isDraftPaused ? ( + + ) : ( + + )} + + +
+ + {/* Help Text */} +
+

+ Force Pick: Automatically selects the top player from the + current team's queue, or the highest EV available player if queue is empty. +

+
+
+ ); +} diff --git a/app/routes.ts b/app/routes.ts index 2426d2e..6b7a80b 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -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"), diff --git a/app/routes/api/draft-actions.ts b/app/routes/api/draft-actions.ts new file mode 100644 index 0000000..269dacb --- /dev/null +++ b/app/routes/api/draft-actions.ts @@ -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 }); + } +} diff --git a/app/routes/api/draft-commissioner.ts b/app/routes/api/draft-commissioner.ts new file mode 100644 index 0000000..ec311a1 --- /dev/null +++ b/app/routes/api/draft-commissioner.ts @@ -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 }); + } +} diff --git a/app/routes/api/draft-pick.ts b/app/routes/api/draft-pick.ts new file mode 100644 index 0000000..0293154 --- /dev/null +++ b/app/routes/api/draft-pick.ts @@ -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 }); + } +} diff --git a/app/routes/leagues/$leagueId.draft.tsx b/app/routes/leagues/$leagueId.draft.tsx index 9aba38e..9860b28 100644 --- a/app/routes/leagues/$leagueId.draft.tsx +++ b/app/routes/leagues/$leagueId.draft.tsx @@ -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 ({ + 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 (
{/* Header */} @@ -213,9 +373,16 @@ export default function DraftRoom({ loaderData }: { loaderData: Awaited

Commissioner Controls

-
- Controls will be implemented in Phase 9 -
+
) : (
@@ -229,6 +396,10 @@ export default function DraftRoom({ loaderData }: { loaderData: AwaitedDraft Rounds: {season.draftRounds}
+
+ Current Team: + {currentTeamName} +
)} diff --git a/server/socket.ts b/server/socket.ts index 8f45ee9..daa3cbb 100644 --- a/server/socket.ts +++ b/server/socket.ts @@ -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; diff --git a/tsconfig.node.json b/tsconfig.node.json index afaea84..a717121 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -4,7 +4,8 @@ "server/**/*.ts", "vite.config.ts", "database/**/*.ts", - "app/contexts/**/*.ts" + "app/contexts/**/*.ts", + "app/models/**/*.ts" ], "compilerOptions": { "composite": true, diff --git a/vite.config.ts b/vite.config.ts index 36dea4b..8f02a76 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -15,4 +15,9 @@ export default defineConfig(({ isSsrBuild }) => ({ server: { allowedHosts: true, }, + resolve: { + alias: { + "~/database": "/database", + }, + }, }));