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

File diff suppressed because it is too large Load diff

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
--- ---