From 9d41508fd0aca07af51417c98ec1c70d9ad707a7 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Sat, 18 Oct 2025 14:55:26 -0700 Subject: [PATCH] feat: add draft API routes and implement draft room pick controls --- app/components/ui/context-menu.tsx | 250 ++++ app/routes.ts | 4 + app/routes/api/draft.force-autopick.ts | 217 ++++ app/routes/api/draft.force-manual-pick.ts | 149 +++ app/routes/api/draft.make-pick.ts | 158 +++ app/routes/api/draft.start.ts | 90 ++ .../leagues/$leagueId.draft.$seasonId.tsx | 1022 ++++++++++++----- database/schema.ts | 15 + package-lock.json | 69 ++ package.json | 1 + plans/draft-room-implementation.md | 322 ++++-- 11 files changed, 1901 insertions(+), 396 deletions(-) create mode 100644 app/components/ui/context-menu.tsx create mode 100644 app/routes/api/draft.force-autopick.ts create mode 100644 app/routes/api/draft.force-manual-pick.ts create mode 100644 app/routes/api/draft.make-pick.ts create mode 100644 app/routes/api/draft.start.ts diff --git a/app/components/ui/context-menu.tsx b/app/components/ui/context-menu.tsx new file mode 100644 index 0000000..bca47a6 --- /dev/null +++ b/app/components/ui/context-menu.tsx @@ -0,0 +1,250 @@ +import * as React from "react" +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +import { cn } from "app/lib/utils" + +function ContextMenu({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function ContextMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function ContextMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function ContextMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function ContextMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function ContextMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/app/routes.ts b/app/routes.ts index 303e1bd..7eac0f5 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -16,6 +16,10 @@ export default [ route("api/queue/remove", "routes/api/queue.remove.ts"), route("api/queue/clear", "routes/api/queue.clear.ts"), route("api/queue/reorder", "routes/api/queue.reorder.ts"), + route("api/draft/make-pick", "routes/api/draft.make-pick.ts"), + route("api/draft/start", "routes/api/draft.start.ts"), + route("api/draft/force-autopick", "routes/api/draft.force-autopick.ts"), + route("api/draft/force-manual-pick", "routes/api/draft.force-manual-pick.ts"), route("user-profile", "routes/user-profile.tsx"), route("how-to-play", "routes/how-to-play.tsx"), route("test-socket", "routes/test-socket.tsx"), diff --git a/app/routes/api/draft.force-autopick.ts b/app/routes/api/draft.force-autopick.ts new file mode 100644 index 0000000..996ff7a --- /dev/null +++ b/app/routes/api/draft.force-autopick.ts @@ -0,0 +1,217 @@ +import { getAuth } from "@clerk/react-router/server"; +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; +import { eq, and, notInArray, desc, asc, inArray } from "drizzle-orm"; + +export async function action(args: any) { + const { request } = args; + const auth = await getAuth(args); + const userId = (auth as any).userId as string | null; + + if (!userId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const formData = await request.formData(); + const seasonId = formData.get("seasonId") as string; + const teamId = formData.get("teamId") as string; + const pickNumber = parseInt(formData.get("pickNumber") as string); + + if (!seasonId || !teamId || !pickNumber) { + return Response.json({ error: "Missing required fields" }, { status: 400 }); + } + + const db = database(); + + // Get season details + const season = await db.query.seasons.findFirst({ + where: eq(schema.seasons.id, seasonId), + with: { + league: true, + }, + }); + + if (!season) { + return Response.json({ error: "Season not found" }, { status: 404 }); + } + + // Check if user is commissioner + if (season.league.createdBy !== userId) { + return Response.json({ error: "Only commissioner can force autopick" }, { status: 403 }); + } + + // Get team's queue + const teamQueue = await db.query.draftQueue.findMany({ + where: eq(schema.draftQueue.teamId, teamId), + orderBy: schema.draftQueue.queuePosition, + with: { + participant: { + with: { + sportsSeason: { + with: { + sport: true, + }, + }, + }, + }, + }, + }); + + // Get already drafted participant IDs + const draftPicks = await db.query.draftPicks.findMany({ + where: eq(schema.draftPicks.seasonId, seasonId), + }); + const draftedParticipantIds = draftPicks.map((p) => p.participantId); + + let participantToPick = null; + + // 1. Try to pick from queue (first non-drafted participant) + for (const queueItem of teamQueue) { + if (!draftedParticipantIds.includes(queueItem.participantId)) { + participantToPick = queueItem.participant; + break; + } + } + + // 2. If no valid queue item, pick highest EV available participant + if (!participantToPick) { + // 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); + + if (sportsSeasonIds.length > 0) { + const availableParticipants = await db + .select({ + id: schema.participants.id, + name: schema.participants.name, + expectedValue: schema.participants.expectedValue, + sportsSeasonId: schema.participants.sportsSeasonId, + }) + .from(schema.participants) + .where( + and( + sportsSeasonIds.length === 1 + ? eq(schema.participants.sportsSeasonId, sportsSeasonIds[0]) + : inArray(schema.participants.sportsSeasonId, sportsSeasonIds), + draftedParticipantIds.length > 0 + ? notInArray(schema.participants.id, draftedParticipantIds) + : undefined + ) + ) + .orderBy(desc(schema.participants.expectedValue), asc(schema.participants.name)) + .limit(1); + + if (availableParticipants.length > 0) { + // Get full participant with sport info + participantToPick = await db.query.participants.findFirst({ + where: eq(schema.participants.id, availableParticipants[0].id), + with: { + sportsSeason: { + with: { + sport: true, + }, + }, + }, + }); + } + } + } + + if (!participantToPick) { + return Response.json({ error: "No available participants to pick" }, { status: 400 }); + } + + // Calculate round and pickInRound + const draftSlots = await db.query.draftSlots.findMany({ + where: eq(schema.draftSlots.seasonId, seasonId), + orderBy: schema.draftSlots.draftOrder, + with: { + team: true, + }, + }); + + const totalTeams = draftSlots.length; + const currentRound = Math.ceil(pickNumber / totalTeams); + const isEvenRound = currentRound % 2 === 0; + let pickInRound = ((pickNumber - 1) % totalTeams) + 1; + if (isEvenRound) { + pickInRound = totalTeams - pickInRound + 1; + } + + // Create the draft pick + const [draftPick] = await db + .insert(schema.draftPicks) + .values({ + seasonId, + teamId, + participantId: participantToPick.id, + pickNumber, + round: currentRound, + pickInRound, + pickedByUserId: userId, + pickedByType: "auto", + }) + .returning(); + + // Update season's current pick number + const nextPickNumber = pickNumber + 1; + const totalPicks = totalTeams * season.draftRounds; + const isDraftComplete = nextPickNumber > totalPicks; + + await db + .update(schema.seasons) + .set({ + currentPickNumber: isDraftComplete ? pickNumber : nextPickNumber, + status: isDraftComplete ? "active" : season.status, + }) + .where(eq(schema.seasons.id, seasonId)); + + // Remove from queue if it was picked from queue + if (teamQueue.some((item) => item.participantId === participantToPick.id)) { + await db + .delete(schema.draftQueue) + .where( + and( + eq(schema.draftQueue.teamId, teamId), + eq(schema.draftQueue.participantId, participantToPick.id) + ) + ); + } + + // Emit socket event + try { + const io = (global as any).__socketIO; + const team = draftSlots.find((slot) => slot.team.id === teamId)?.team; + + io.to(`draft-${seasonId}`).emit("pick-made", { + pick: { + ...draftPick, + team, + participant: { + ...participantToPick, + sport: participantToPick.sportsSeason.sport, + }, + sport: participantToPick.sportsSeason.sport, + }, + nextPickNumber: isDraftComplete ? pickNumber : nextPickNumber, + isDraftComplete, + }); + + if (isDraftComplete) { + io.to(`draft-${seasonId}`).emit("draft-completed"); + } + } catch (error) { + console.error("Socket.IO error:", error); + } + + return Response.json({ + success: true, + pick: draftPick, + participant: participantToPick, + nextPickNumber: isDraftComplete ? pickNumber : nextPickNumber, + isDraftComplete, + }); +} diff --git a/app/routes/api/draft.force-manual-pick.ts b/app/routes/api/draft.force-manual-pick.ts new file mode 100644 index 0000000..8ee5473 --- /dev/null +++ b/app/routes/api/draft.force-manual-pick.ts @@ -0,0 +1,149 @@ +import { getAuth } from "@clerk/react-router/server"; +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; +import { eq, and } from "drizzle-orm"; + +export async function action(args: any) { + const { request } = args; + const auth = await getAuth(args); + const userId = (auth as any).userId as string | null; + + if (!userId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const formData = await request.formData(); + const seasonId = formData.get("seasonId") as string; + const teamId = formData.get("teamId") as string; + const participantId = formData.get("participantId") as string; + const pickNumber = parseInt(formData.get("pickNumber") as string); + + if (!seasonId || !teamId || !participantId || !pickNumber) { + return Response.json({ error: "Missing required fields" }, { status: 400 }); + } + + const db = database(); + + // Get season details + const season = await db.query.seasons.findFirst({ + where: eq(schema.seasons.id, seasonId), + with: { + league: true, + }, + }); + + if (!season) { + return Response.json({ error: "Season not found" }, { status: 404 }); + } + + // Check if user is commissioner + if (season.league.createdBy !== userId) { + return Response.json({ error: "Only commissioner can force manual pick" }, { status: 403 }); + } + + // Check if participant is already drafted + const existingPick = await db.query.draftPicks.findFirst({ + where: and( + eq(schema.draftPicks.seasonId, seasonId), + eq(schema.draftPicks.participantId, participantId) + ), + }); + + if (existingPick) { + return Response.json({ error: "Participant already drafted" }, { status: 400 }); + } + + // Get participant details + const participant = await db.query.participants.findFirst({ + where: eq(schema.participants.id, participantId), + with: { + sportsSeason: { + with: { + sport: true, + }, + }, + }, + }); + + if (!participant) { + return Response.json({ error: "Participant not found" }, { status: 404 }); + } + + // Calculate round and pickInRound + const draftSlots = await db.query.draftSlots.findMany({ + where: eq(schema.draftSlots.seasonId, seasonId), + orderBy: schema.draftSlots.draftOrder, + with: { + team: true, + }, + }); + + const totalTeams = draftSlots.length; + const currentRound = Math.ceil(pickNumber / totalTeams); + const isEvenRound = currentRound % 2 === 0; + let pickInRound = ((pickNumber - 1) % totalTeams) + 1; + if (isEvenRound) { + pickInRound = totalTeams - pickInRound + 1; + } + + // Create the draft pick + const [draftPick] = await db + .insert(schema.draftPicks) + .values({ + seasonId, + teamId, + participantId, + pickNumber, + round: currentRound, + pickInRound, + pickedByUserId: userId, + pickedByType: "commissioner", + }) + .returning(); + + // Update season's current pick number + const nextPickNumber = pickNumber + 1; + const totalPicks = totalTeams * season.draftRounds; + const isDraftComplete = nextPickNumber > totalPicks; + + await db + .update(schema.seasons) + .set({ + currentPickNumber: isDraftComplete ? pickNumber : nextPickNumber, + status: isDraftComplete ? "active" : season.status, + }) + .where(eq(schema.seasons.id, seasonId)); + + // Emit socket event + try { + const io = (global as any).__socketIO; + const team = draftSlots.find((slot) => slot.team.id === teamId)?.team; + + io.to(`draft-${seasonId}`).emit("pick-made", { + pick: { + ...draftPick, + team, + participant: { + ...participant, + sport: participant.sportsSeason.sport, + }, + sport: participant.sportsSeason.sport, + }, + nextPickNumber: isDraftComplete ? pickNumber : nextPickNumber, + isDraftComplete, + }); + + if (isDraftComplete) { + io.to(`draft-${seasonId}`).emit("draft-completed"); + } + } catch (error) { + console.error("Socket.IO error:", error); + } + + return Response.json({ + success: true, + pick: draftPick, + nextPickNumber: isDraftComplete ? pickNumber : nextPickNumber, + isDraftComplete, + }); +} diff --git a/app/routes/api/draft.make-pick.ts b/app/routes/api/draft.make-pick.ts new file mode 100644 index 0000000..4e803c9 --- /dev/null +++ b/app/routes/api/draft.make-pick.ts @@ -0,0 +1,158 @@ +import { getAuth } from "@clerk/react-router/server"; +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; +import { eq, and } from "drizzle-orm"; + +export async function action(args: any) { + const { request } = args; + const auth = await getAuth(args); + const userId = (auth as any).userId as string | null; + + if (!userId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const formData = await request.formData(); + const seasonId = formData.get("seasonId") as string; + const participantId = formData.get("participantId") as string; + + if (!seasonId || !participantId) { + return Response.json({ error: "Missing required fields" }, { status: 400 }); + } + + const db = database(); + + // Get season details + const season = await db.query.seasons.findFirst({ + where: eq(schema.seasons.id, seasonId), + with: { + league: true, + }, + }); + + if (!season) { + return Response.json({ error: "Season not found" }, { status: 404 }); + } + + // Get current draft slot (who should be picking now) + const currentPickNumber = season.currentPickNumber || 1; + const draftSlots = await db.query.draftSlots.findMany({ + where: eq(schema.draftSlots.seasonId, seasonId), + orderBy: schema.draftSlots.draftOrder, + with: { + team: true, + }, + }); + + const totalTeams = draftSlots.length; + const currentRound = Math.ceil(currentPickNumber / totalTeams); + const isSnakeDraft = true; // Assuming snake draft + const isEvenRound = currentRound % 2 === 0; + + // Calculate which team should pick + let pickInRound = ((currentPickNumber - 1) % totalTeams) + 1; + if (isSnakeDraft && isEvenRound) { + pickInRound = totalTeams - pickInRound + 1; + } + + const currentDraftSlot = draftSlots.find((slot) => slot.draftOrder === pickInRound); + + if (!currentDraftSlot) { + return Response.json({ error: "Invalid draft state" }, { status: 500 }); + } + + // Check permissions: must be team owner or commissioner + const isTeamOwner = currentDraftSlot.team.ownerId === userId; + const isCommissioner = season.league.createdBy === userId; + + if (!isTeamOwner && !isCommissioner) { + return Response.json({ error: "Not your turn to pick" }, { status: 403 }); + } + + // Check if participant is already drafted + const existingPick = await db.query.draftPicks.findFirst({ + where: and( + eq(schema.draftPicks.seasonId, seasonId), + eq(schema.draftPicks.participantId, participantId) + ), + }); + + if (existingPick) { + return Response.json({ error: "Participant already drafted" }, { status: 400 }); + } + + // Get participant details + const participant = await db.query.participants.findFirst({ + where: eq(schema.participants.id, participantId), + with: { + sportsSeason: { + with: { + sport: true, + }, + }, + }, + }); + + if (!participant) { + return Response.json({ error: "Participant not found" }, { status: 404 }); + } + + // Create the draft pick + const [draftPick] = await db + .insert(schema.draftPicks) + .values({ + seasonId, + teamId: currentDraftSlot.teamId, + participantId, + pickNumber: currentPickNumber, + round: currentRound, + pickInRound, + pickedByUserId: userId, + pickedByType: isTeamOwner ? "owner" : "commissioner", + }) + .returning(); + + // Update season's current pick number + const nextPickNumber = currentPickNumber + 1; + const totalPicks = totalTeams * season.draftRounds; + const isDraftComplete = nextPickNumber > totalPicks; + + await db + .update(schema.seasons) + .set({ + currentPickNumber: isDraftComplete ? currentPickNumber : nextPickNumber, + status: isDraftComplete ? "active" : season.status, + }) + .where(eq(schema.seasons.id, seasonId)); + + // Emit socket event + try { + const io = (global as any).__socketIO; + io.to(`draft-${seasonId}`).emit("pick-made", { + pick: { + ...draftPick, + team: currentDraftSlot.team, + participant: { + ...participant, + sport: participant.sportsSeason.sport, + }, + sport: participant.sportsSeason.sport, + }, + nextPickNumber: isDraftComplete ? currentPickNumber : nextPickNumber, + isDraftComplete, + }); + + if (isDraftComplete) { + io.to(`draft-${seasonId}`).emit("draft-completed"); + } + } catch (error) { + console.error("Socket.IO error:", error); + } + + return Response.json({ + success: true, + pick: draftPick, + nextPickNumber: isDraftComplete ? currentPickNumber : nextPickNumber, + isDraftComplete, + }); +} diff --git a/app/routes/api/draft.start.ts b/app/routes/api/draft.start.ts new file mode 100644 index 0000000..77d0d3b --- /dev/null +++ b/app/routes/api/draft.start.ts @@ -0,0 +1,90 @@ +import { getAuth } from "@clerk/react-router/server"; +import { database } from "~/database/context"; +import * as schema from "~/database/schema"; +import { eq } from "drizzle-orm"; + +export async function action(args: any) { + const { request } = args; + const auth = await getAuth(args); + const userId = (auth as any).userId as string | null; + + if (!userId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const formData = await request.formData(); + const seasonId = formData.get("seasonId") as string; + + if (!seasonId) { + return Response.json({ error: "Missing seasonId" }, { status: 400 }); + } + + const db = database(); + + // Get season details + const season = await db.query.seasons.findFirst({ + where: eq(schema.seasons.id, seasonId), + with: { + league: true, + }, + }); + + if (!season) { + return Response.json({ error: "Season not found" }, { status: 404 }); + } + + // Check if user is commissioner + if (season.league.createdBy !== userId) { + return Response.json({ error: "Only commissioner can start draft" }, { status: 403 }); + } + + // Check if draft already started + if (season.status === "draft" || season.status === "active" || season.status === "completed") { + return Response.json({ error: "Draft already started or completed" }, { status: 400 }); + } + + // Update season status to draft + await db + .update(schema.seasons) + .set({ + status: "draft", + currentPickNumber: 1, + }) + .where(eq(schema.seasons.id, seasonId)); + + // Initialize timers for all teams + const draftSlots = await db.query.draftSlots.findMany({ + where: eq(schema.draftSlots.seasonId, seasonId), + }); + + const initialTime = season.draftInitialTime || 120; // Default 2 minutes + + // Delete existing timers for this season + await db + .delete(schema.draftTimers) + .where(eq(schema.draftTimers.seasonId, seasonId)); + + // Insert new timers for all teams + for (const slot of draftSlots) { + await db + .insert(schema.draftTimers) + .values({ + seasonId, + teamId: slot.teamId, + timeRemaining: initialTime, + }); + } + + // Emit socket event + try { + const io = (global as any).__socketIO; + io.to(`draft-${seasonId}`).emit("draft-started", { + seasonId, + currentPickNumber: 1, + }); + } catch (error) { + console.error("Socket.IO error:", error); + } + + return Response.json({ success: true }); +} diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index b4b769b..92b91d5 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -1,5 +1,5 @@ import { useLoaderData, Link } from "react-router"; -import { eq, and, asc, desc, notInArray, inArray } from "drizzle-orm"; +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"; @@ -9,19 +9,27 @@ import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; import { getAuth } from "@clerk/react-router/server"; import { getTeamQueue } from "~/models/draft-queue"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "~/components/ui/context-menu"; 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 }); + throw new Response("You must be logged in to view the draft room", { + status: 401, + }); } const db = database(); @@ -49,7 +57,9 @@ export async function loader(args: any) { }); if (!userTeam && !isCommissioner) { - throw new Response("You do not have access to this draft room", { status: 403 }); + 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 @@ -78,9 +88,18 @@ export async function loader(args: any) { }) .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)) + .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)); @@ -88,14 +107,13 @@ export async function loader(args: any) { const seasonSports = await db.query.seasonSports.findMany({ where: eq(schema.seasonSports.seasonId, seasonId), }); - + const sportsSeasonIds = seasonSports.map((ss) => ss.sportsSeasonId); - // Get available participants (not yet picked) - sorted by EV desc, then name - const pickedParticipantIds = draftPicks.map((p) => p.participant.id); - + // 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({ @@ -105,19 +123,23 @@ export async function loader(args: any) { 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( - and( - sportsSeasonIds.length === 1 - ? eq(schema.participants.sportsSeasonId, sportsSeasonIds[0]) - : inArray(schema.participants.sportsSeasonId, sportsSeasonIds), - pickedParticipantIds.length > 0 - ? notInArray(schema.participants.id, pickedParticipantIds) - : undefined - ) + .innerJoin( + schema.sportsSeasons, + eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id) ) - .orderBy(desc(schema.participants.expectedValue), asc(schema.participants.name)); + .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) + ); } // Load user's team queue if they have a team @@ -136,8 +158,15 @@ export async function loader(args: any) { } export default function DraftRoom() { - const { season, draftSlots, draftPicks, availableParticipants, userTeam, userQueue, isCommissioner: _isCommissioner } = - useLoaderData(); + const { + season, + draftSlots, + draftPicks, + availableParticipants, + userTeam, + userQueue, + isCommissioner, + } = useLoaderData(); const { isConnected, on, off } = useDraftSocket(season.id); const [picks, setPicks] = useState(draftPicks); const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1); @@ -145,6 +174,12 @@ export default function DraftRoom() { const [hideDrafted, setHideDrafted] = useState(true); const [sportFilter, setSportFilter] = useState("all"); const [queue, setQueue] = useState(userQueue); + const [timeRemaining, setTimeRemaining] = useState(null); + const [forcePickDialogOpen, setForcePickDialogOpen] = useState(false); + const [selectedPickSlot, setSelectedPickSlot] = useState<{ + pickNumber: number; + teamId: string; + } | null>(null); // Listen for new picks from other users useEffect(() => { @@ -154,12 +189,20 @@ export default function DraftRoom() { setCurrentPick(data.nextPickNumber); }; + const handleTimerUpdate = (data: any) => { + if (data.currentPickNumber === currentPick) { + setTimeRemaining(data.timeRemaining); + } + }; + on("pick-made", handlePickMade); + on("timer-update", handleTimerUpdate); return () => { off("pick-made", handlePickMade); + off("timer-update", handleTimerUpdate); }; - }, [on, off]); + }, [on, off, currentPick]); // Queue handlers const handleAddToQueue = async (participantId: string) => { @@ -215,28 +258,148 @@ export default function DraftRoom() { } }; + 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 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 + + // Format timer display + const formatTime = (seconds: number | null) => { + if (seconds === null) return "--:--"; + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; + } + return `${minutes}:${String(secs).padStart(2, "0")}`; + }; + + // Get timer color based on time remaining + const getTimerColor = (seconds: number | null) => { + if (seconds === null) return "text-muted-foreground"; + if (seconds > 60) return "text-green-600 dark:text-green-400"; + if (seconds > 30) return "text-yellow-600 dark:text-yellow-400"; + if (seconds > 10) return "text-red-600 dark:text-red-400"; + return "text-red-600 dark:text-red-400 animate-pulse"; + }; + // Generate snake draft grid structure const generateDraftGrid = () => { const teamCount = draftSlots.length; const rounds = season.draftRounds; - const grid: Array> = []; + 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, @@ -247,56 +410,43 @@ export default function DraftRoom() { } 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)); + 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(); - // Get all participants (including drafted) for the show/hide drafted toggle - const allParticipants = [...availableParticipants]; - - // Add drafted participants if we're showing them - if (!hideDrafted) { - picks.forEach((pick: any) => { - if (!allParticipants.find((p: any) => p.id === pick.participant.id)) { - allParticipants.push({ - id: pick.participant.id, - name: pick.participant.name, - expectedValue: pick.participant.expectedValue || 0, - sport: pick.sport, - }); - } - }); - // Re-sort after adding drafted players - allParticipants.sort((a, b) => { - if (b.expectedValue !== a.expectedValue) { - return b.expectedValue - a.expectedValue; - } - return a.name.localeCompare(b.name); - }); - } - // Filter participants based on search, sport, and drafted status - const filteredParticipants = allParticipants.filter((participant: any) => { - // Search filter - if (searchQuery && !participant.name.toLowerCase().includes(searchQuery.toLowerCase())) { - return false; + const filteredParticipants = availableParticipants.filter( + (participant: any) => { + // Drafted filter - hide drafted participants by default + if (hideDrafted && draftedParticipantIds.has(participant.id)) { + 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; } - // Sport filter - if (sportFilter !== "all" && participant.sport.name !== sportFilter) { - return false; - } - return true; - }); + ); return (
@@ -311,10 +461,24 @@ export default function DraftRoom() {
Round: {currentRound} Pick: {currentPick} - {season.status.replace("_", " ")} + + {season.status.replace("_", " ")} + + {season.status === "draft" && ( + + ⏱ {formatTime(timeRemaining)} + + )}
+ {/* Commissioner Controls */} + {isCommissioner && season.status === "pre_draft" && ( + + )} +
@@ -346,10 +508,7 @@ export default function DraftRoom() { {/* Team Headers */}
{draftSlots.map((slot) => ( -
+
{slot.team.name}
@@ -365,47 +524,107 @@ export default function DraftRoom() { {draftGrid.map((roundPicks, roundIndex) => { const round = roundIndex + 1; const isEvenRound = round % 2 === 0; - const displayPicks = isEvenRound ? [...roundPicks].reverse() : roundPicks; - + const displayPicks = isEvenRound + ? [...roundPicks].reverse() + : roundPicks; + return ( -
- {displayPicks.map((cell) => { - const isCurrent = cell.pickNumber === currentPick; - const isPicked = !!cell.pick; - - return ( -
-
- {cell.round}.{String(cell.pickInRound).padStart(2, "0")} +
+ {displayPicks.map((cell) => { + const isCurrent = cell.pickNumber === currentPick; + const isPicked = !!cell.pick; + + return isCommissioner && !isPicked && isCurrent ? ( + + +
+
+ {cell.round}. + {String(cell.pickInRound).padStart(2, "0")} +
+ {isPicked ? ( +
+
+ {cell.pick.participant.name} +
+
+ {cell.pick.sport.name} +
+
+ ) : isCurrent ? ( +
+ On Clock +
+ ) : null} +
+
+ + + handleForceAutopick( + cell.pickNumber, + cell.teamId + ) + } + > + Force Auto Pick + + { + setSelectedPickSlot({ + pickNumber: cell.pickNumber, + teamId: cell.teamId, + }); + setForcePickDialogOpen(true); + }} + > + Force Manual Pick + + +
+ ) : ( +
+
+ {cell.round}. + {String(cell.pickInRound).padStart(2, "0")} +
+ {isPicked ? ( +
+
+ {cell.pick.participant.name} +
+
+ {cell.pick.sport.name} +
+
+ ) : isCurrent ? ( +
+ On Clock +
+ ) : null}
- {isPicked ? ( -
-
- {cell.pick.participant.name} -
-
- {cell.pick.sport.name} -
-
- ) : isCurrent ? ( -
- On Clock -
- ) : null} -
- ); - })} -
+ ); + })} +
); })}
@@ -415,237 +634,418 @@ export default function DraftRoom() {
- {/* My Queue */} - {userTeam && ( -
- -
-

My Queue ({queue.length})

- {queue.length > 0 && ( - + {/* My Queue */} + {userTeam && ( +
+ +
+

+ My Queue ({queue.length}) +

+ {queue.length > 0 && ( + + )} +
+ {queue.length === 0 ? ( +

+ Click participants to add to your queue +

+ ) : ( +
+ {queue.map((item: any, index: number) => ( +
+
+ {index + 1} +
+

+ {availableParticipants.find( + (p: any) => p.id === item.participantId + )?.name || "Unknown"} +

+
+
+ +
+ ))} +
)} -
- {queue.length === 0 ? ( -

- Click participants to add to your queue -

+ +
+ )} + + {/* Recent Picks */} +
+ +

Recent Picks

+ {picks.length === 0 ? ( +

No picks yet...

) : (
- {queue.map((item: any, index: number) => ( -
-
- {index + 1} -
-

- {availableParticipants.find((p: any) => p.id === item.participantId)?.name || "Unknown"} + {picks + .slice() + .reverse() + .slice(0, 10) + .map((pick: any) => ( +

+
+ #{pick.pickNumber} +
+

+ {pick.participant.name} +

+

+ {pick.sport.name} +

+
+
+
+

{pick.team.name}

+

+ Round {pick.round}

- -
- ))} + ))}
)}
- )} - {/* Recent Picks */} -
- -

Recent Picks

- {picks.length === 0 ? ( -

No picks yet...

- ) : ( -
- {picks - .slice() - .reverse() - .slice(0, 10) - .map((pick: any) => ( -
+ +
+

+ Available Participants +

+ +
+ {/* Search Input */} + setSearchQuery(e.target.value)} + className="w-full px-3 py-2 border rounded-md text-sm" + /> + +
+ {/* Sport Filter Dropdown */} + - {/* Right Column: Available Participants */} -
- -
-

- Available Participants ({filteredParticipants.length}) -

- -
- {/* Search Input */} + {/* Show/Hide Drafted Toggle */} + +
+
+
+ + {/* Table */} +
+
+ + + + + + + {userTeam && ( + + )} + + + + {filteredParticipants.length === 0 ? ( + + + + ) : ( + filteredParticipants.map((participant: any) => { + const isDrafted = draftedParticipantIds.has( + participant.id + ); + const isInQueue = queue.some( + (item: any) => item.participantId === participant.id + ); + + return ( + + + + + {userTeam && ( + + )} + + ); + }) + )} + +
+ Participant + SportEV + Queue +
+ No participants found +
+
+ + {participant.name} + + {isDrafted && ( + + Drafted + + )} + {isInQueue && !isDrafted && ( + + Queued + + )} +
+
+ {participant.sport.name} + + {participant.expectedValue} + +
+ {/* Pick button - only show if it's your turn and participant not drafted */} + {canPick && !isDrafted && ( + <> + + {/* Queue icon button next to Pick */} + {!isInQueue ? ( + + ) : ( + + )} + + )} + + {/* Queue buttons - only show if not your turn */} + {!canPick && !isDrafted && !isInQueue && ( + + )} + {!canPick && !isDrafted && isInQueue && ( + + )} +
+
+
+
+ +
+
+
+ + {/* Force Manual Pick Dialog */} + {forcePickDialogOpen && selectedPickSlot && ( +
setForcePickDialogOpen(false)} + > + e.stopPropagation()} + > +
+

Force Manual Pick

+

+ Select a participant to draft for Pick # + {selectedPickSlot.pickNumber} +

+ + {/* Search and Filter */} +
setSearchQuery(e.target.value)} - className="w-full px-3 py-2 border rounded-md text-sm" /> - -
- {/* Sport Filter Dropdown */} - - - {/* Show/Hide Drafted Toggle */} - -
+
-
- - {/* Table */} -
-
+ + {/* Participant List */} +
- + - {userTeam && } + - {filteredParticipants.length === 0 ? ( - - - - ) : ( - filteredParticipants.map((participant: any) => { - const isDrafted = draftedParticipantIds.has(participant.id); - const isInQueue = queue.some((item: any) => item.participantId === participant.id); - - return ( - - - - - {userTeam && ( - - )} - - ); - }) - )} + {filteredParticipants + .filter((p: any) => !draftedParticipantIds.has(p.id)) + .map((participant: any) => ( + + + + + + + ))}
Participant + Participant + Sport EVQueueAction
- No participants found -
-
- {participant.name} - {isDrafted && ( - - Drafted - - )} - {isInQueue && !isDrafted && ( - - Queued - - )} -
-
- {participant.sport.name} - - {participant.expectedValue} - - {!isDrafted && !isInQueue && ( - - )} - {!isDrafted && isInQueue && ( - - )} -
+ {participant.name} + + {participant.sport.name} + + {participant.expectedValue} + + +
+ +
+ +
-
-
+ )}
); } diff --git a/database/schema.ts b/database/schema.ts index efc5282..307079c 100644 --- a/database/schema.ts +++ b/database/schema.ts @@ -342,3 +342,18 @@ export const draftSlotsRelations = relations(draftSlots, ({ one }) => ({ references: [teams.id], }), })); + +export const draftQueueRelations = relations(draftQueue, ({ one }) => ({ + season: one(seasons, { + fields: [draftQueue.seasonId], + references: [seasons.id], + }), + team: one(teams, { + fields: [draftQueue.teamId], + references: [teams.id], + }), + participant: one(participants, { + fields: [draftQueue.participantId], + references: [participants.id], + }), +})); diff --git a/package-lock.json b/package-lock.json index 20ae1b8..1718ef7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "@clerk/react-router": "^2.1.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-navigation-menu": "^1.2.14", @@ -1819,6 +1820,34 @@ } } }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dialog": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", @@ -1978,6 +2007,46 @@ } } }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-navigation-menu": { "version": "1.2.14", "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", diff --git a/package.json b/package.json index 8689ff3..8ce8d9d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@clerk/react-router": "^2.1.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-navigation-menu": "^1.2.14", diff --git a/plans/draft-room-implementation.md b/plans/draft-room-implementation.md index c5f8727..cd92899 100644 --- a/plans/draft-room-implementation.md +++ b/plans/draft-room-implementation.md @@ -1,14 +1,43 @@ # Draft Room Implementation Plan +## ✅ IMPLEMENTATION STATUS: 90% COMPLETE + +**Last Updated:** October 18, 2025 + +### Quick Summary +- ✅ **Phases 1-7**: COMPLETE (Database, Settings, Socket.IO, Draft Room, Grid, Player List, Queue) +- 🟡 **Phase 8**: PARTIAL (Timer display done, server-side countdown NOT implemented) +- ✅ **Phase 9**: COMPLETE (Draft actions, commissioner controls, force pick) +- 🟡 **Phase 10**: PARTIAL (Pick updates work, timer sync not implemented) +- ❌ **Phase 11**: NOT STARTED (Mobile polish) + +### What Works Now +- Full draft room with snake draft grid +- Real-time pick updates via Socket.IO +- Team queues with add/remove/clear +- Commissioner force pick (auto + manual) +- Search, filter, and hide drafted toggle +- Start draft functionality +- Timer display (client-side only) + +### What's Missing +- Server-side timer countdown +- Auto-pick when timer expires +- Pause/Resume draft +- Timer persistence across server restarts +- Mobile responsive tweaks + +--- + ## Overview Build a real-time draft room using Socket.IO for live updates, with snake draft grid, player selection, per-team queue, and chess clock timers. --- -## Phase 1: Database Schema & Models +## Phase 1: Database Schema & Models ✅ COMPLETE -### 1.1 New Tables +### 1.1 New Tables ✅ COMPLETE **`draft_picks`** @@ -48,7 +77,7 @@ Build a real-time draft room using Socket.IO for live updates, with snake draft - updated_at (timestamp) ``` -### 1.2 Schema Updates +### 1.2 Schema Updates ✅ COMPLETE **Update `seasons` table:** @@ -60,25 +89,23 @@ Build a real-time draft room using Socket.IO for live updates, with snake draft - draft_paused (boolean, default false) ``` -### 1.3 Model Functions +### 1.3 Model Functions 🟡 PARTIAL -- `createDraftPick()` -- `getDraftPicks(seasonId)` -- `getCurrentPick(seasonId)` -- `addToQueue(teamId, participantId)` -- `removeFromQueue(queueId)` -- `reorderQueue(teamId, participantIds[])` -- `getTeamQueue(teamId)` -- `updateTeamTimer(teamId, timeRemaining)` -- `getTeamTimers(seasonId)` -- `initializeDraftTimers(seasonId)` - set initial time for all teams -- `autoPickForTeam(teamId)` - pick from queue or top EV +✅ **Implemented:** +- `getTeamQueue(teamId)` - in `app/models/draft-queue.ts` +- `addToQueue()`, `removeFromQueue()`, `clearTeamQueue()`, `reorderQueueWithSeason()` - in `app/models/draft-queue.ts` +- Draft pick creation - in API routes + +❌ **Not Implemented:** +- `updateTeamTimer(teamId, timeRemaining)` - not needed yet (no server-side countdown) +- `getTeamTimers(seasonId)` - not needed yet +- Standalone model functions (using inline queries in API routes instead) --- -## Phase 2: League Settings Updates +## Phase 2: League Settings Updates ✅ COMPLETE -### 2.1 Add Timer Configuration to Settings Page +### 2.1 Add Timer Configuration to Settings Page ✅ COMPLETE - Add fields to "Draft Rounds" card: - Initial Time (seconds) - default 120 @@ -88,9 +115,9 @@ Build a real-time draft room using Socket.IO for live updates, with snake draft --- -## Phase 3: Socket.IO Setup +## Phase 3: Socket.IO Setup ✅ COMPLETE -### Overview +### Overview ✅ COMPLETE Socket.IO requires access to the HTTP server instance (not just the Express app). The key pattern is: ```javascript @@ -428,9 +455,9 @@ import { getSocketIO } from "~/server/socket.js"; --- -## Phase 4: Draft Room Route & Layout +## Phase 4: Draft Room Route & Layout ✅ COMPLETE -### 4.1 Route Setup +### 4.1 Route Setup ✅ COMPLETE - Create `/app/routes/leagues/$leagueId.draft.tsx` - Loader: @@ -480,9 +507,9 @@ import { getSocketIO } from "~/server/socket.js"; --- -## Phase 5: Draft Grid Component +## Phase 5: Draft Grid Component ✅ COMPLETE -### 5.1 Calculate Snake Order +### 5.1 Calculate Snake Order ✅ COMPLETE - Use existing draft slots for team order - Generate pick order: @@ -516,9 +543,9 @@ import { getSocketIO } from "~/server/socket.js"; --- -## Phase 6: Player List Component +## Phase 6: Player List Component ✅ COMPLETE -### 6.1 Player List Features +### 6.1 Player List Features ✅ COMPLETE - Display all participants from season sports - Show: "Participant Name - Sport" @@ -541,28 +568,34 @@ import { getSocketIO } from "~/server/socket.js"; --- -## Phase 7: Queue Component +## Phase 7: Queue Component ✅ COMPLETE -### 7.1 Queue Features +### 7.1 Queue Features 🟡 MOSTLY COMPLETE +✅ **Implemented:** - Show only current user's team queue - Display in order (1, 2, 3, ...) -- Drag & drop to reorder - Remove button for each item - "Clear Queue" button -- Real-time sync via Socket.IO +- Queue icon buttons next to Pick button -### 7.2 Queue Actions +❌ **Not Implemented:** +- Drag & drop to reorder (API route exists, UI not implemented) +- Real-time Socket.IO sync (using HTTP requests + page state instead) -- Add player → emit `add-to-queue` → update DB → emit `queue-updated` to team -- Remove → emit `remove-from-queue` → update DB → emit `queue-updated` -- Reorder → emit `reorder-queue` → update DB → emit `queue-updated` +### 7.2 Queue Actions ✅ COMPLETE + +✅ **Implemented via API routes:** +- `/api/queue/add` - Add player to queue +- `/api/queue/remove` - Remove from queue +- `/api/queue/clear` - Clear entire queue +- `/api/queue/reorder` - Reorder queue (API ready, UI not implemented) --- -## Phase 8: Timer System +## Phase 8: Timer System 🟡 PARTIAL (50% Complete) -### 8.1 Timer Display +### 8.1 Timer Display ✅ COMPLETE - Show under each team name in grid - Format: @@ -574,37 +607,33 @@ import { getSocketIO } from "~/server/socket.js"; - Red: < 30s - Flashing red: < 10s -### 8.2 Timer Logic (Server-Side) +### 8.2 Timer Logic (Server-Side) ❌ NOT IMPLEMENTED -- When draft starts: initialize all timers with `draft_initial_time` -- During a team's turn: - - Their timer counts down every second - - Other teams' timers remain paused -- When pick is made: - - Stop countdown for current team - - Add `draft_increment_time` to current team's timer - - Move to next pick - - Start next team's timer countdown -- Emit `timer-update` every second to all clients -- When timer reaches 0: trigger `autoPickForTeam()` +**What's Missing:** +- Server-side countdown interval +- Emit `timer-update` every second +- Auto-pick when timer reaches 0 +- Timer increment after pick +- Timer persistence -### 8.3 Auto-Pick Function +**Note:** Timer display shows `--:--` because no server-side countdown is running. This is the main missing piece for a fully functional draft. -```typescript -async function autoPickForTeam(teamId: string, seasonId: string) { - // 1. Check queue - pick first item - // 2. If queue empty, pick highest EV participant not drafted - // 3. Create draft pick with picked_by_type='auto' - // 4. Emit pick-made event - // 5. Move to next pick -} -``` +### 8.3 Auto-Pick Function 🟡 PARTIAL + +✅ **Implemented:** +- `/api/draft/force-autopick` - Commissioner can force auto-pick +- Checks queue first, then picks highest EV +- Creates pick with `picked_by_type='auto'` + +❌ **Not Implemented:** +- Automatic trigger when timer reaches 0 +- Server-side timer countdown to trigger it --- -## Phase 9: Draft Actions & Permissions +## Phase 9: Draft Actions & Permissions ✅ COMPLETE -### 9.1 Making a Pick +### 9.1 Making a Pick ✅ COMPLETE - Only current team owner or commissioner can pick - Click participant in player list (if current turn) @@ -614,33 +643,44 @@ async function autoPickForTeam(teamId: string, seasonId: string) { - Update current pick - Update timers -### 9.2 Commissioner Controls +### 9.2 Commissioner Controls 🟡 MOSTLY COMPLETE -- Start Draft button (if not started) +✅ **Implemented:** +- Start Draft button (shows when status is "pre_draft") +- Force Auto Pick (right-click context menu on current pick) +- Force Manual Pick (right-click context menu, opens participant dialog) +- `/api/draft/start` - Starts draft, initializes timers +- `/api/draft/make-pick` - Regular pick by team owner +- `/api/draft/force-autopick` - Commissioner force auto-pick +- `/api/draft/force-manual-pick` - Commissioner manual pick for any team + +❌ **Not Implemented:** - Pause/Resume button -- Force Pick button (for current team) -- Show in header, only visible to commissioners +- Pause/Resume API routes -### 9.3 Draft Start Logic +### 9.3 Draft Start Logic ✅ COMPLETE -- Can be started manually by commissioner -- Or auto-start when `draftDateTime` is reached (background job?) -- Change season status to "drafting" -- Initialize timers -- Emit `draft-started` +✅ **Implemented:** +- Manual start by commissioner via "Start Draft" button +- Changes season status to "draft" +- Initializes timers for all teams +- Emits `draft-started` socket event -### 9.4 Draft Completion +❌ **Not Implemented:** +- Auto-start when `draftDateTime` is reached (would need background job/cron) -- When all picks made (picks = teams × rounds): - - Change season status to "active" - - Emit `draft-completed` - - Stop timers +### 9.4 Draft Completion ✅ COMPLETE + +✅ **Implemented:** +- Detects when all picks made +- Changes season status to "active" +- Emits `draft-completed` socket event --- -## Phase 10: Real-Time Updates +## Phase 10: Real-Time Updates 🟡 PARTIAL (60% Complete) -### 10.1 Client State Management +### 10.1 Client State Management ✅ COMPLETE - Use React state for: - Draft picks array @@ -650,23 +690,35 @@ async function autoPickForTeam(teamId: string, seasonId: string) { - Available participants - Update state when Socket.IO events received -### 10.2 Optimistic Updates +### 10.2 Optimistic Updates 🟡 PARTIAL -- When user makes pick: update UI immediately -- If server rejects: revert and show error -- For queue operations: update immediately +✅ **Implemented:** +- Pick updates broadcast via Socket.IO `pick-made` event +- All clients receive and display picks in real-time +- Queue operations update local state immediately + +❌ **Not Implemented:** +- Timer updates via Socket.IO (no server-side countdown) +- Revert on server rejection (currently just shows alert) + +**Note:** Real-time updates work for picks but not for timers since server-side countdown isn't implemented. --- -## Phase 11: Mobile Responsiveness +## Phase 11: Mobile Responsiveness ❌ NOT STARTED -### 11.1 Mobile Layout Adjustments +### 11.1 Mobile Layout Adjustments ❌ NOT STARTED -- Draft grid: horizontal scroll -- Stack player list and queue vertically +**What's Needed:** +- Draft grid: horizontal scroll (partially works) +- Stack player list and queue vertically on mobile - Collapsible sections for player list/queue - Larger touch targets for buttons - Simplified timer display +- Test on actual mobile devices +- Responsive breakpoints optimization + +**Current State:** Desktop layout works, mobile usable but not optimized. --- @@ -686,13 +738,113 @@ async function autoPickForTeam(teamId: string, seasonId: string) { --- +## ✅ IMPLEMENTATION COMPLETION SUMMARY + +### Files Created/Modified + +**API Routes:** +- ✅ `/app/routes/api/draft.make-pick.ts` - Team owner makes pick +- ✅ `/app/routes/api/draft.start.ts` - Commissioner starts draft +- ✅ `/app/routes/api/draft.force-autopick.ts` - Commissioner force auto-pick +- ✅ `/app/routes/api/draft.force-manual-pick.ts` - Commissioner manual pick +- ✅ `/app/routes/api/queue.add.ts` - Add to queue +- ✅ `/app/routes/api/queue.remove.ts` - Remove from queue +- ✅ `/app/routes/api/queue.clear.ts` - Clear queue +- ✅ `/app/routes/api/queue.reorder.ts` - Reorder queue (API ready, UI not implemented) + +**Models:** +- ✅ `/app/models/draft-queue.ts` - Queue management functions + +**Routes:** +- ✅ `/app/routes/leagues/$leagueId.draft.$seasonId.tsx` - Main draft room + +**Hooks:** +- ✅ `/app/hooks/useDraftSocket.ts` - Socket.IO client hook + +**Server:** +- ✅ `/server/socket.js` - Socket.IO server setup +- ✅ `server.js` - Modified to use HTTP server for Socket.IO + +**Components:** +- ✅ `/app/components/ui/context-menu.tsx` - Added for commissioner controls + +**Database:** +- ✅ Schema updated with draft tables and timer fields +- ✅ `draftQueueRelations` added to schema + +### What Works Right Now + +1. **Full Draft Flow:** + - Commissioner can start draft + - Team owners can make picks on their turn + - Commissioner can force picks (auto or manual) + - Real-time pick updates via Socket.IO + - Draft completion detection + +2. **Queue Management:** + - Add/remove/clear queue + - Queue persists in database + - Queue icon buttons next to Pick button + - Queue used by force autopick + +3. **UI Features:** + - Snake draft grid with pick numbers + - Search participants by name + - Filter by sport + - Hide/show drafted toggle + - Context menu for commissioner force pick + - Responsive grid (horizontal scroll) + +4. **Real-Time:** + - Socket.IO connection indicator + - Pick updates broadcast to all users + - Room-based isolation (multiple drafts can run simultaneously) + +### What's Missing (10%) + +1. **Server-Side Timer Countdown** (Main Missing Piece): + - No interval running on server + - No `timer-update` socket events + - Timer display shows `--:--` + - No automatic trigger for auto-pick + +2. **Pause/Resume:** + - No pause/resume buttons + - No API routes for pause/resume + +3. **Minor Features:** + - Drag & drop queue reorder UI + - Mobile optimization + - Auto-start at `draftDateTime` + +### To Complete the Draft Room (Remaining 10%) + +**Priority 1: Server-Side Timer (Critical)** +1. Add interval in `server/socket.js` to countdown every second +2. Emit `timer-update` to draft room +3. Trigger auto-pick when timer reaches 0 +4. Add timer increment after pick + +**Priority 2: Pause/Resume (Nice to Have)** +1. Add pause/resume buttons for commissioner +2. Create `/api/draft/pause` and `/api/draft/resume` routes +3. Update timer logic to respect pause state + +**Priority 3: Polish (Optional)** +1. Drag & drop queue reorder UI +2. Mobile responsive improvements +3. Auto-start background job + +--- + ## Open Questions / Future Considerations - **Background job for auto-start**: How to trigger draft start at `draftDateTime`? Cron job? Polling? -- **Timer persistence**: What if server restarts during draft? Store timer state in DB? +- **Timer persistence**: What if server restarts during draft? Store timer state in DB and resume? - **Undo picks**: Not in initial scope, but may want later - **Draft history/log**: Separate view to see all picks in order? - **Notifications**: Alert users when it's their turn? (email, push, etc.) +- **Multi-server**: If scaling to multiple servers, need Redis adapter for Socket.IO ---