feat: add draft API routes and implement draft room pick controls

This commit is contained in:
Chris Parsons 2025-10-18 14:55:26 -07:00
parent 1ad14bfef1
commit 9d41508fd0
11 changed files with 1901 additions and 396 deletions

View file

@ -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<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View file

@ -16,6 +16,10 @@ export default [
route("api/queue/remove", "routes/api/queue.remove.ts"), route("api/queue/remove", "routes/api/queue.remove.ts"),
route("api/queue/clear", "routes/api/queue.clear.ts"), route("api/queue/clear", "routes/api/queue.clear.ts"),
route("api/queue/reorder", "routes/api/queue.reorder.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("user-profile", "routes/user-profile.tsx"),
route("how-to-play", "routes/how-to-play.tsx"), route("how-to-play", "routes/how-to-play.tsx"),
route("test-socket", "routes/test-socket.tsx"), route("test-socket", "routes/test-socket.tsx"),

View file

@ -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,
});
}

View file

@ -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,
});
}

View file

@ -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,
});
}

View file

@ -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 });
}

View file

@ -1,5 +1,5 @@
import { useLoaderData, Link } from "react-router"; 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 { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { useDraftSocket } from "~/hooks/useDraftSocket"; import { useDraftSocket } from "~/hooks/useDraftSocket";
@ -9,6 +9,12 @@ import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { getAuth } from "@clerk/react-router/server"; import { getAuth } from "@clerk/react-router/server";
import { getTeamQueue } from "~/models/draft-queue"; import { getTeamQueue } from "~/models/draft-queue";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "~/components/ui/context-menu";
export async function loader(args: any) { export async function loader(args: any) {
const { params } = args; const { params } = args;
@ -21,7 +27,9 @@ export async function loader(args: any) {
} }
if (!userId) { 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(); const db = database();
@ -49,7 +57,9 @@ export async function loader(args: any) {
}); });
if (!userTeam && !isCommissioner) { 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 // Get draft slots (draft order) - using select instead of query builder
@ -78,9 +88,18 @@ export async function loader(args: any) {
}) })
.from(schema.draftPicks) .from(schema.draftPicks)
.innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id)) .innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id))
.innerJoin(schema.participants, eq(schema.draftPicks.participantId, schema.participants.id)) .innerJoin(
.innerJoin(schema.sportsSeasons, eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id)) schema.participants,
.innerJoin(schema.sports, eq(schema.sportsSeasons.sportId, schema.sports.id)) 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)) .where(eq(schema.draftPicks.seasonId, seasonId))
.orderBy(asc(schema.draftPicks.pickNumber)); .orderBy(asc(schema.draftPicks.pickNumber));
@ -91,9 +110,8 @@ export async function loader(args: any) {
const sportsSeasonIds = seasonSports.map((ss) => ss.sportsSeasonId); const sportsSeasonIds = seasonSports.map((ss) => ss.sportsSeasonId);
// Get available participants (not yet picked) - sorted by EV desc, then name // Get ALL participants (both drafted and undrafted) - sorted by EV desc, then name
const pickedParticipantIds = draftPicks.map((p) => p.participant.id); // Filtering will be done on the client side based on the "Show Drafted" toggle
let availableParticipants: any[] = []; let availableParticipants: any[] = [];
if (sportsSeasonIds.length > 0) { if (sportsSeasonIds.length > 0) {
@ -105,19 +123,23 @@ export async function loader(args: any) {
sport: schema.sports, sport: schema.sports,
}) })
.from(schema.participants) .from(schema.participants)
.innerJoin(schema.sportsSeasons, eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id)) .innerJoin(
.innerJoin(schema.sports, eq(schema.sportsSeasons.sportId, schema.sports.id)) schema.sportsSeasons,
eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id)
)
.innerJoin(
schema.sports,
eq(schema.sportsSeasons.sportId, schema.sports.id)
)
.where( .where(
and(
sportsSeasonIds.length === 1 sportsSeasonIds.length === 1
? eq(schema.participants.sportsSeasonId, sportsSeasonIds[0]) ? eq(schema.participants.sportsSeasonId, sportsSeasonIds[0])
: inArray(schema.participants.sportsSeasonId, sportsSeasonIds), : inArray(schema.participants.sportsSeasonId, sportsSeasonIds)
pickedParticipantIds.length > 0
? notInArray(schema.participants.id, pickedParticipantIds)
: undefined
) )
) .orderBy(
.orderBy(desc(schema.participants.expectedValue), asc(schema.participants.name)); desc(schema.participants.expectedValue),
asc(schema.participants.name)
);
} }
// Load user's team queue if they have a team // Load user's team queue if they have a team
@ -136,8 +158,15 @@ export async function loader(args: any) {
} }
export default function DraftRoom() { export default function DraftRoom() {
const { season, draftSlots, draftPicks, availableParticipants, userTeam, userQueue, isCommissioner: _isCommissioner } = const {
useLoaderData<typeof loader>(); season,
draftSlots,
draftPicks,
availableParticipants,
userTeam,
userQueue,
isCommissioner,
} = useLoaderData<typeof loader>();
const { isConnected, on, off } = useDraftSocket(season.id); const { isConnected, on, off } = useDraftSocket(season.id);
const [picks, setPicks] = useState(draftPicks); const [picks, setPicks] = useState(draftPicks);
const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1); const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1);
@ -145,6 +174,12 @@ export default function DraftRoom() {
const [hideDrafted, setHideDrafted] = useState(true); const [hideDrafted, setHideDrafted] = useState(true);
const [sportFilter, setSportFilter] = useState<string>("all"); const [sportFilter, setSportFilter] = useState<string>("all");
const [queue, setQueue] = useState(userQueue); const [queue, setQueue] = useState(userQueue);
const [timeRemaining, setTimeRemaining] = useState<number | null>(null);
const [forcePickDialogOpen, setForcePickDialogOpen] = useState(false);
const [selectedPickSlot, setSelectedPickSlot] = useState<{
pickNumber: number;
teamId: string;
} | null>(null);
// Listen for new picks from other users // Listen for new picks from other users
useEffect(() => { useEffect(() => {
@ -154,12 +189,20 @@ export default function DraftRoom() {
setCurrentPick(data.nextPickNumber); setCurrentPick(data.nextPickNumber);
}; };
const handleTimerUpdate = (data: any) => {
if (data.currentPickNumber === currentPick) {
setTimeRemaining(data.timeRemaining);
}
};
on("pick-made", handlePickMade); on("pick-made", handlePickMade);
on("timer-update", handleTimerUpdate);
return () => { return () => {
off("pick-made", handlePickMade); off("pick-made", handlePickMade);
off("timer-update", handleTimerUpdate);
}; };
}, [on, off]); }, [on, off, currentPick]);
// Queue handlers // Queue handlers
const handleAddToQueue = async (participantId: string) => { const handleAddToQueue = async (participantId: string) => {
@ -215,14 +258,134 @@ 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 // Calculate current round
const currentRound = Math.ceil(currentPick / draftSlots.length); 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 // Generate snake draft grid structure
const generateDraftGrid = () => { const generateDraftGrid = () => {
const teamCount = draftSlots.length; const teamCount = draftSlots.length;
const rounds = season.draftRounds; const rounds = season.draftRounds;
const grid: Array<Array<{ pickNumber: number; round: number; pickInRound: number; teamId: string; pick?: any }>> = []; const grid: Array<
Array<{
pickNumber: number;
round: number;
pickInRound: number;
teamId: string;
pick?: any;
}>
> = [];
for (let round = 1; round <= rounds; round++) { for (let round = 1; round <= rounds; round++) {
const roundPicks = []; const roundPicks = [];
@ -254,41 +417,27 @@ export default function DraftRoom() {
const draftGrid = generateDraftGrid(); const draftGrid = generateDraftGrid();
// Get drafted participant IDs for filtering // 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 // Get unique sports for filter dropdown
const uniqueSports = Array.from( const uniqueSports = Array.from(
new Set(availableParticipants.map((p: any) => p.sport.name)) new Set(availableParticipants.map((p: any) => p.sport.name))
).sort(); ).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 // Filter participants based on search, sport, and drafted status
const filteredParticipants = allParticipants.filter((participant: any) => { const filteredParticipants = availableParticipants.filter(
(participant: any) => {
// Drafted filter - hide drafted participants by default
if (hideDrafted && draftedParticipantIds.has(participant.id)) {
return false;
}
// Search filter // Search filter
if (searchQuery && !participant.name.toLowerCase().includes(searchQuery.toLowerCase())) { if (
searchQuery &&
!participant.name.toLowerCase().includes(searchQuery.toLowerCase())
) {
return false; return false;
} }
// Sport filter // Sport filter
@ -296,7 +445,8 @@ export default function DraftRoom() {
return false; return false;
} }
return true; return true;
}); }
);
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
@ -311,10 +461,24 @@ export default function DraftRoom() {
<div className="flex gap-4 text-sm text-muted-foreground mt-1"> <div className="flex gap-4 text-sm text-muted-foreground mt-1">
<span>Round: {currentRound}</span> <span>Round: {currentRound}</span>
<span>Pick: {currentPick}</span> <span>Pick: {currentPick}</span>
<span className="capitalize">{season.status.replace("_", " ")}</span> <span className="capitalize">
{season.status.replace("_", " ")}
</span>
{season.status === "draft" && (
<span
className={`font-mono font-semibold ${getTimerColor(timeRemaining)}`}
>
{formatTime(timeRemaining)}
</span>
)}
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Commissioner Controls */}
{isCommissioner && season.status === "pre_draft" && (
<Button onClick={handleStartDraft}>Start Draft</Button>
)}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
className={`w-3 h-3 rounded-full ${ className={`w-3 h-3 rounded-full ${
@ -326,9 +490,7 @@ export default function DraftRoom() {
</span> </span>
</div> </div>
<Button variant="outline" asChild> <Button variant="outline" asChild>
<Link to={`/leagues/${season.leagueId}`}> <Link to={`/leagues/${season.leagueId}`}>Exit Draft Room</Link>
Exit Draft Room
</Link>
</Button> </Button>
</div> </div>
</div> </div>
@ -346,10 +508,7 @@ export default function DraftRoom() {
{/* Team Headers */} {/* Team Headers */}
<div className="flex gap-2 mb-2"> <div className="flex gap-2 mb-2">
{draftSlots.map((slot) => ( {draftSlots.map((slot) => (
<div <div key={slot.id} className="flex-1 min-w-32 text-center">
key={slot.id}
className="flex-1 min-w-32 text-center"
>
<div className="font-semibold text-sm truncate px-2"> <div className="font-semibold text-sm truncate px-2">
{slot.team.name} {slot.team.name}
</div> </div>
@ -365,7 +524,9 @@ export default function DraftRoom() {
{draftGrid.map((roundPicks, roundIndex) => { {draftGrid.map((roundPicks, roundIndex) => {
const round = roundIndex + 1; const round = roundIndex + 1;
const isEvenRound = round % 2 === 0; const isEvenRound = round % 2 === 0;
const displayPicks = isEvenRound ? [...roundPicks].reverse() : roundPicks; const displayPicks = isEvenRound
? [...roundPicks].reverse()
: roundPicks;
return ( return (
<div key={roundIndex} className="flex gap-2"> <div key={roundIndex} className="flex gap-2">
@ -373,7 +534,64 @@ export default function DraftRoom() {
const isCurrent = cell.pickNumber === currentPick; const isCurrent = cell.pickNumber === currentPick;
const isPicked = !!cell.pick; const isPicked = !!cell.pick;
return ( return isCommissioner && !isPicked && isCurrent ? (
<ContextMenu key={cell.pickNumber}>
<ContextMenuTrigger asChild>
<div
className={`flex-1 min-w-32 h-20 border-2 rounded-lg p-2 transition-all ${
isCurrent
? "border-blue-500 bg-blue-50 dark:bg-blue-950 shadow-lg"
: isPicked
? "border-green-500 bg-green-50 dark:bg-green-950"
: "border-gray-300 bg-white dark:bg-gray-900"
}`}
title={`Overall Pick #${cell.pickNumber}`}
>
<div className="text-xs font-mono text-muted-foreground mb-1">
{cell.round}.
{String(cell.pickInRound).padStart(2, "0")}
</div>
{isPicked ? (
<div className="text-xs">
<div className="font-semibold truncate">
{cell.pick.participant.name}
</div>
<div className="text-muted-foreground truncate">
{cell.pick.sport.name}
</div>
</div>
) : isCurrent ? (
<div className="text-xs font-semibold text-blue-600 dark:text-blue-400">
On Clock
</div>
) : null}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() =>
handleForceAutopick(
cell.pickNumber,
cell.teamId
)
}
>
Force Auto Pick
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
setSelectedPickSlot({
pickNumber: cell.pickNumber,
teamId: cell.teamId,
});
setForcePickDialogOpen(true);
}}
>
Force Manual Pick
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
) : (
<div <div
key={cell.pickNumber} key={cell.pickNumber}
className={`flex-1 min-w-32 h-20 border-2 rounded-lg p-2 transition-all ${ className={`flex-1 min-w-32 h-20 border-2 rounded-lg p-2 transition-all ${
@ -386,7 +604,8 @@ export default function DraftRoom() {
title={`Overall Pick #${cell.pickNumber}`} title={`Overall Pick #${cell.pickNumber}`}
> >
<div className="text-xs font-mono text-muted-foreground mb-1"> <div className="text-xs font-mono text-muted-foreground mb-1">
{cell.round}.{String(cell.pickInRound).padStart(2, "0")} {cell.round}.
{String(cell.pickInRound).padStart(2, "0")}
</div> </div>
{isPicked ? ( {isPicked ? (
<div className="text-xs"> <div className="text-xs">
@ -420,7 +639,9 @@ export default function DraftRoom() {
<div> <div>
<Card className="p-4"> <Card className="p-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">My Queue ({queue.length})</h2> <h2 className="text-xl font-semibold">
My Queue ({queue.length})
</h2>
{queue.length > 0 && ( {queue.length > 0 && (
<Button <Button
variant="outline" variant="outline"
@ -446,7 +667,9 @@ export default function DraftRoom() {
<Badge variant="default">{index + 1}</Badge> <Badge variant="default">{index + 1}</Badge>
<div> <div>
<p className="font-semibold text-sm"> <p className="font-semibold text-sm">
{availableParticipants.find((p: any) => p.id === item.participantId)?.name || "Unknown"} {availableParticipants.find(
(p: any) => p.id === item.participantId
)?.name || "Unknown"}
</p> </p>
</div> </div>
</div> </div>
@ -511,7 +734,7 @@ export default function DraftRoom() {
<Card className="p-4"> <Card className="p-4">
<div className="mb-4"> <div className="mb-4">
<h2 className="text-xl font-semibold mb-3"> <h2 className="text-xl font-semibold mb-3">
Available Participants ({filteredParticipants.length}) Available Participants
</h2> </h2>
<div className="space-y-2"> <div className="space-y-2">
@ -559,23 +782,36 @@ export default function DraftRoom() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-muted sticky top-0"> <thead className="bg-muted sticky top-0">
<tr> <tr>
<th className="text-left p-3 font-semibold">Participant</th> <th className="text-left p-3 font-semibold">
Participant
</th>
<th className="text-left p-3 font-semibold">Sport</th> <th className="text-left p-3 font-semibold">Sport</th>
<th className="text-right p-3 font-semibold">EV</th> <th className="text-right p-3 font-semibold">EV</th>
{userTeam && <th className="text-right p-3 font-semibold">Queue</th>} {userTeam && (
<th className="text-right p-3 font-semibold">
Queue
</th>
)}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredParticipants.length === 0 ? ( {filteredParticipants.length === 0 ? (
<tr> <tr>
<td colSpan={3} className="text-center py-8 text-muted-foreground"> <td
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
No participants found No participants found
</td> </td>
</tr> </tr>
) : ( ) : (
filteredParticipants.map((participant: any) => { filteredParticipants.map((participant: any) => {
const isDrafted = draftedParticipantIds.has(participant.id); const isDrafted = draftedParticipantIds.has(
const isInQueue = queue.some((item: any) => item.participantId === participant.id); participant.id
);
const isInQueue = queue.some(
(item: any) => item.participantId === participant.id
);
return ( return (
<tr <tr
@ -588,14 +824,22 @@ export default function DraftRoom() {
> >
<td className="p-3"> <td className="p-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium">{participant.name}</span> <span className="font-medium">
{participant.name}
</span>
{isDrafted && ( {isDrafted && (
<Badge variant="secondary" className="text-xs"> <Badge
variant="secondary"
className="text-xs"
>
Drafted Drafted
</Badge> </Badge>
)} )}
{isInQueue && !isDrafted && ( {isInQueue && !isDrafted && (
<Badge variant="default" className="text-xs"> <Badge
variant="default"
className="text-xs"
>
Queued Queued
</Badge> </Badge>
)} )}
@ -609,21 +853,77 @@ export default function DraftRoom() {
</td> </td>
{userTeam && ( {userTeam && (
<td className="p-3 text-right"> <td className="p-3 text-right">
{!isDrafted && !isInQueue && ( <div className="flex gap-2 justify-end items-center">
{/* Pick button - only show if it's your turn and participant not drafted */}
{canPick && !isDrafted && (
<>
<Button <Button
variant="outline" variant="default"
size="sm" size="sm"
onClick={() => handleAddToQueue(participant.id)} onClick={() =>
handleMakePick(participant.id)
}
> >
Add Pick
</Button> </Button>
)} {/* Queue icon button next to Pick */}
{!isDrafted && isInQueue && ( {!isInQueue ? (
<Button
variant="ghost"
size="sm"
onClick={() =>
handleAddToQueue(participant.id)
}
title="Add to queue"
>
<span className="text-lg">+</span>
</Button>
) : (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => { onClick={() => {
const queueItem = queue.find((item: any) => item.participantId === participant.id); const queueItem = queue.find(
(item: any) =>
item.participantId ===
participant.id
);
if (queueItem) {
handleRemoveFromQueue(
queueItem.id
);
}
}}
title="Remove from queue"
>
<span className="text-lg"></span>
</Button>
)}
</>
)}
{/* Queue buttons - only show if not your turn */}
{!canPick && !isDrafted && !isInQueue && (
<Button
variant="outline"
size="sm"
onClick={() =>
handleAddToQueue(participant.id)
}
>
Add to Queue
</Button>
)}
{!canPick && !isDrafted && isInQueue && (
<Button
variant="ghost"
size="sm"
onClick={() => {
const queueItem = queue.find(
(item: any) =>
item.participantId ===
participant.id
);
if (queueItem) { if (queueItem) {
handleRemoveFromQueue(queueItem.id); handleRemoveFromQueue(queueItem.id);
} }
@ -632,6 +932,7 @@ export default function DraftRoom() {
Remove Remove
</Button> </Button>
)} )}
</div>
</td> </td>
)} )}
</tr> </tr>
@ -646,6 +947,105 @@ export default function DraftRoom() {
</div> </div>
</div> </div>
</div> </div>
{/* Force Manual Pick Dialog */}
{forcePickDialogOpen && selectedPickSlot && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onClick={() => setForcePickDialogOpen(false)}
>
<Card
className="w-full max-w-2xl max-h-[80vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6">
<h2 className="text-2xl font-bold mb-4">Force Manual Pick</h2>
<p className="text-sm text-muted-foreground mb-4">
Select a participant to draft for Pick #
{selectedPickSlot.pickNumber}
</p>
{/* Search and Filter */}
<div className="flex gap-2 mb-4">
<input
type="text"
placeholder="Search participants..."
className="flex-1 px-3 py-2 border rounded-md"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<select
className="px-3 py-2 border rounded-md"
value={sportFilter}
onChange={(e) => setSportFilter(e.target.value)}
>
<option value="all">All Sports</option>
{uniqueSports.map((sport) => (
<option key={sport} value={sport}>
{sport}
</option>
))}
</select>
</div>
{/* Participant List */}
<div className="max-h-96 overflow-y-auto border rounded-lg">
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0">
<tr>
<th className="text-left p-3 font-semibold">
Participant
</th>
<th className="text-left p-3 font-semibold">Sport</th>
<th className="text-right p-3 font-semibold">EV</th>
<th className="text-right p-3 font-semibold">Action</th>
</tr>
</thead>
<tbody>
{filteredParticipants
.filter((p: any) => !draftedParticipantIds.has(p.id))
.map((participant: any) => (
<tr
key={participant.id}
className="border-t hover:bg-muted/50"
>
<td className="p-3 font-medium">
{participant.name}
</td>
<td className="p-3 text-muted-foreground">
{participant.sport.name}
</td>
<td className="p-3 text-right font-mono font-semibold">
{participant.expectedValue}
</td>
<td className="p-3 text-right">
<Button
size="sm"
onClick={() =>
handleForceManualPick(participant.id)
}
>
Draft
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setForcePickDialogOpen(false)}
>
Cancel
</Button>
</div>
</div>
</Card>
</div>
)}
</div> </div>
); );
} }

View file

@ -342,3 +342,18 @@ export const draftSlotsRelations = relations(draftSlots, ({ one }) => ({
references: [teams.id], 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],
}),
}));

69
package-lock.json generated
View file

@ -9,6 +9,7 @@
"@clerk/react-router": "^2.1.0", "@clerk/react-router": "^2.1.0",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3", "@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-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.14", "@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": { "node_modules/@radix-ui/react-dialog": {
"version": "1.1.15", "version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", "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": { "node_modules/@radix-ui/react-navigation-menu": {
"version": "1.2.14", "version": "1.2.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz",

View file

@ -15,6 +15,7 @@
"@clerk/react-router": "^2.1.0", "@clerk/react-router": "^2.1.0",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3", "@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-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-navigation-menu": "^1.2.14",

View file

@ -1,14 +1,43 @@
# Draft Room Implementation Plan # 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 ## 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. 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`** **`draft_picks`**
@ -48,7 +77,7 @@ Build a real-time draft room using Socket.IO for live updates, with snake draft
- updated_at (timestamp) - updated_at (timestamp)
``` ```
### 1.2 Schema Updates ### 1.2 Schema Updates ✅ COMPLETE
**Update `seasons` table:** **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) - draft_paused (boolean, default false)
``` ```
### 1.3 Model Functions ### 1.3 Model Functions 🟡 PARTIAL
- `createDraftPick()` ✅ **Implemented:**
- `getDraftPicks(seasonId)` - `getTeamQueue(teamId)` - in `app/models/draft-queue.ts`
- `getCurrentPick(seasonId)` - `addToQueue()`, `removeFromQueue()`, `clearTeamQueue()`, `reorderQueueWithSeason()` - in `app/models/draft-queue.ts`
- `addToQueue(teamId, participantId)` - Draft pick creation - in API routes
- `removeFromQueue(queueId)`
- `reorderQueue(teamId, participantIds[])` ❌ **Not Implemented:**
- `getTeamQueue(teamId)` - `updateTeamTimer(teamId, timeRemaining)` - not needed yet (no server-side countdown)
- `updateTeamTimer(teamId, timeRemaining)` - `getTeamTimers(seasonId)` - not needed yet
- `getTeamTimers(seasonId)` - Standalone model functions (using inline queries in API routes instead)
- `initializeDraftTimers(seasonId)` - set initial time for all teams
- `autoPickForTeam(teamId)` - pick from queue or top EV
--- ---
## 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: - Add fields to "Draft Rounds" card:
- Initial Time (seconds) - default 120 - 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: Socket.IO requires access to the HTTP server instance (not just the Express app). The key pattern is:
```javascript ```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` - Create `/app/routes/leagues/$leagueId.draft.tsx`
- Loader: - 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 - Use existing draft slots for team order
- Generate pick 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 - Display all participants from season sports
- Show: "Participant Name - Sport" - 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 - Show only current user's team queue
- Display in order (1, 2, 3, ...) - Display in order (1, 2, 3, ...)
- Drag & drop to reorder
- Remove button for each item - Remove button for each item
- "Clear Queue" button - "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 ### 7.2 Queue Actions ✅ COMPLETE
- Remove → emit `remove-from-queue` → update DB → emit `queue-updated`
- Reorder → emit `reorder-queue` → update DB → emit `queue-updated` ✅ **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 - Show under each team name in grid
- Format: - Format:
@ -574,37 +607,33 @@ import { getSocketIO } from "~/server/socket.js";
- Red: < 30s - Red: < 30s
- Flashing red: < 10s - 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` **What's Missing:**
- During a team's turn: - Server-side countdown interval
- Their timer counts down every second - Emit `timer-update` every second
- Other teams' timers remain paused - Auto-pick when timer reaches 0
- When pick is made: - Timer increment after pick
- Stop countdown for current team - Timer persistence
- 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()`
### 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 ### 8.3 Auto-Pick Function 🟡 PARTIAL
async function autoPickForTeam(teamId: string, seasonId: string) {
// 1. Check queue - pick first item ✅ **Implemented:**
// 2. If queue empty, pick highest EV participant not drafted - `/api/draft/force-autopick` - Commissioner can force auto-pick
// 3. Create draft pick with picked_by_type='auto' - Checks queue first, then picks highest EV
// 4. Emit pick-made event - Creates pick with `picked_by_type='auto'`
// 5. Move to next pick
} ❌ **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 - Only current team owner or commissioner can pick
- Click participant in player list (if current turn) - Click participant in player list (if current turn)
@ -614,33 +643,44 @@ async function autoPickForTeam(teamId: string, seasonId: string) {
- Update current pick - Update current pick
- Update timers - 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 - Pause/Resume button
- Force Pick button (for current team) - Pause/Resume API routes
- Show in header, only visible to commissioners
### 9.3 Draft Start Logic ### 9.3 Draft Start Logic ✅ COMPLETE
- Can be started manually by commissioner ✅ **Implemented:**
- Or auto-start when `draftDateTime` is reached (background job?) - Manual start by commissioner via "Start Draft" button
- Change season status to "drafting" - Changes season status to "draft"
- Initialize timers - Initializes timers for all teams
- Emit `draft-started` - 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): ### 9.4 Draft Completion ✅ COMPLETE
- Change season status to "active"
- Emit `draft-completed` ✅ **Implemented:**
- Stop timers - 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: - Use React state for:
- Draft picks array - Draft picks array
@ -650,23 +690,35 @@ async function autoPickForTeam(teamId: string, seasonId: string) {
- Available participants - Available participants
- Update state when Socket.IO events received - Update state when Socket.IO events received
### 10.2 Optimistic Updates ### 10.2 Optimistic Updates 🟡 PARTIAL
- When user makes pick: update UI immediately ✅ **Implemented:**
- If server rejects: revert and show error - Pick updates broadcast via Socket.IO `pick-made` event
- For queue operations: update immediately - 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 **What's Needed:**
- Stack player list and queue vertically - Draft grid: horizontal scroll (partially works)
- Stack player list and queue vertically on mobile
- Collapsible sections for player list/queue - Collapsible sections for player list/queue
- Larger touch targets for buttons - Larger touch targets for buttons
- Simplified timer display - 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 ## Open Questions / Future Considerations
- **Background job for auto-start**: How to trigger draft start at `draftDateTime`? Cron job? Polling? - **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 - **Undo picks**: Not in initial scope, but may want later
- **Draft history/log**: Separate view to see all picks in order? - **Draft history/log**: Separate view to see all picks in order?
- **Notifications**: Alert users when it's their turn? (email, push, etc.) - **Notifications**: Alert users when it's their turn? (email, push, etc.)
- **Multi-server**: If scaling to multiple servers, need Redis adapter for Socket.IO
--- ---