Revert Phase 8 and 9 implementations back to 0bef91e

This commit is contained in:
Chris Parsons 2025-10-16 10:17:31 -07:00
parent 95554e2664
commit b9743aacc1
21 changed files with 13 additions and 2123 deletions

View file

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

View file

@ -1,183 +0,0 @@
import { useMemo } from "react";
import { DraftTimer } from "./DraftTimer";
interface Team {
id: string;
name: string;
draftOrder: number;
ownerId: string | null;
}
interface DraftPick {
id: string;
teamId: string;
participantId: string;
pickNumber: number;
round: number;
pickInRound: number;
pickedByType: "owner" | "commissioner" | "auto";
}
interface Participant {
id: string;
name: string;
sport: string;
}
interface Timer {
teamId: string;
timeRemaining: number;
}
interface DraftGridProps {
teams: Team[];
draftPicks: DraftPick[];
participants: Participant[];
draftRounds: number;
currentPickNumber: number;
userTeamId?: string;
timers?: Timer[];
}
export function DraftGrid({
teams,
draftPicks,
participants,
draftRounds,
currentPickNumber,
userTeamId,
timers = [],
}: DraftGridProps) {
// Create a map of participant IDs to participant data for quick lookup
const participantMap = useMemo(() => {
return new Map(participants.map((p) => [p.id, p]));
}, [participants]);
// Create a map of pick numbers to draft picks
const pickMap = useMemo(() => {
return new Map(draftPicks.map((p) => [p.pickNumber, p]));
}, [draftPicks]);
// Create a map of team IDs to timers
const timerMap = useMemo(() => {
return new Map(timers.map((t) => [t.teamId, t]));
}, [timers]);
// Calculate pick number for a given round and team
const getPickNumber = (round: number, teamIndex: number): number => {
const isOddRound = round % 2 === 1;
const pickInRound = isOddRound ? teamIndex + 1 : teams.length - teamIndex;
return (round - 1) * teams.length + pickInRound;
};
return (
<div className="overflow-x-auto">
<div className="inline-block min-w-full">
{/* Header Row - Team Names */}
<div className="flex border-b-2 border-border bg-muted/50 sticky top-0 z-10">
<div className="w-16 flex-shrink-0 p-2 font-semibold text-sm border-r">
Round
</div>
{teams.map((team) => {
const timer = timerMap.get(team.id);
// Determine if this team is on the clock
const currentPick = Math.ceil(currentPickNumber / teams.length);
const pickInRound = ((currentPickNumber - 1) % teams.length) + 1;
const isOddRound = currentPick % 2 === 1;
const currentTeamIndex = isOddRound ? pickInRound - 1 : teams.length - pickInRound;
const isOnClock = teams[currentTeamIndex]?.id === team.id;
return (
<div
key={team.id}
className={`flex-1 min-w-[140px] p-2 text-center font-semibold text-sm border-r last:border-r-0 ${
team.id === userTeamId ? "bg-primary/10" : ""
}`}
>
<div className="truncate" title={team.name}>
{team.name}
</div>
{timer && (
<div className="mt-1">
<DraftTimer timeRemaining={timer.timeRemaining} isActive={isOnClock} />
</div>
)}
</div>
);
})}
</div>
{/* Draft Rounds */}
{Array.from({ length: draftRounds }, (_, roundIndex) => {
const round = roundIndex + 1;
const isOddRound = round % 2 === 1;
return (
<div key={round} className="flex border-b border-border hover:bg-muted/30">
{/* Round Number */}
<div className="w-16 flex-shrink-0 p-2 text-center font-medium text-sm border-r bg-muted/30">
{round}
</div>
{/* Picks for this round */}
{teams.map((team, teamIndex) => {
const pickNumber = getPickNumber(round, teamIndex);
const pick = pickMap.get(pickNumber);
const participant = pick ? participantMap.get(pick.participantId) : null;
const isCurrentPick = pickNumber === currentPickNumber;
const isPastPick = pickNumber < currentPickNumber;
const isUserTeam = team.id === userTeamId;
return (
<div
key={team.id}
className={`flex-1 min-w-[140px] p-2 border-r last:border-r-0 ${
isUserTeam ? "bg-primary/5" : ""
} ${isCurrentPick ? "ring-2 ring-primary ring-inset" : ""}`}
>
<div className="flex flex-col gap-1">
{/* Pick Number */}
<div className="text-xs text-muted-foreground">
{round}.{String(isOddRound ? teamIndex + 1 : teams.length - teamIndex).padStart(2, "0")}
</div>
{/* Participant or Status */}
{participant ? (
<div className="space-y-0.5">
<div className="font-medium text-sm truncate" title={participant.name}>
{participant.name}
</div>
<div className="text-xs text-muted-foreground truncate">
{participant.sport}
</div>
{pick && pick.pickedByType !== "owner" && (
<div className="text-xs text-muted-foreground italic">
({pick.pickedByType})
</div>
)}
</div>
) : isCurrentPick ? (
<div className="text-sm font-medium text-primary">
On the clock
</div>
) : isPastPick ? (
<div className="text-xs text-muted-foreground italic">
Skipped
</div>
) : (
<div className="text-xs text-muted-foreground">
-
</div>
)}
</div>
</div>
);
})}
</div>
);
})}
</div>
</div>
);
}

View file

@ -1,125 +0,0 @@
import { useState } from "react";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { GripVertical, X } from "lucide-react";
interface Participant {
id: string;
name: string;
sport: string;
expectedValue: number;
}
interface QueueItem {
id: string;
participantId: string;
queuePosition: number;
}
interface DraftQueueProps {
queueItems: QueueItem[];
participants: Participant[];
onReorder?: (participantIds: string[]) => void;
onRemove?: (queueItemId: string) => void;
}
export function DraftQueue({
queueItems,
participants,
onReorder,
onRemove,
}: DraftQueueProps) {
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
// Create a map for quick participant lookup
const participantMap = new Map(participants.map((p) => [p.id, p]));
// Sort queue items by position
const sortedQueue = [...queueItems].sort((a, b) => a.queuePosition - b.queuePosition);
const handleDragStart = (index: number) => {
setDraggedIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === index) return;
// Reorder the items
const newQueue = [...sortedQueue];
const draggedItem = newQueue[draggedIndex];
newQueue.splice(draggedIndex, 1);
newQueue.splice(index, 0, draggedItem);
// Update positions and notify parent
const participantIds = newQueue.map((item) => item.participantId);
onReorder?.(participantIds);
setDraggedIndex(index);
};
const handleDragEnd = () => {
setDraggedIndex(null);
};
if (sortedQueue.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
<p>Your queue is empty</p>
<p className="text-sm mt-2">Add players from the Available Players list</p>
</div>
);
}
return (
<div className="space-y-2">
{sortedQueue.map((item, index) => {
const participant = participantMap.get(item.participantId);
if (!participant) return null;
return (
<div
key={item.id}
draggable
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
className={`flex items-center gap-2 p-3 rounded-lg border bg-card transition-all ${
draggedIndex === index ? "opacity-50" : "hover:bg-accent cursor-move"
}`}
>
{/* Drag Handle */}
<div className="flex items-center gap-2 text-muted-foreground">
<GripVertical className="h-4 w-4" />
<span className="text-sm font-semibold w-6">{index + 1}</span>
</div>
{/* Player Info */}
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{participant.name}</div>
<div className="flex items-center gap-2 mt-1">
<Badge variant="secondary" className="text-xs">
{participant.sport}
</Badge>
<span className="text-xs text-muted-foreground">
EV: {participant.expectedValue.toFixed(1)}
</span>
</div>
</div>
{/* Remove Button */}
{onRemove && (
<Button
variant="ghost"
size="sm"
onClick={() => onRemove(item.id)}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
);
})}
</div>
);
}

View file

@ -1,37 +0,0 @@
interface DraftTimerProps {
timeRemaining: number; // in seconds
isActive?: boolean;
}
export function DraftTimer({ timeRemaining, isActive = false }: DraftTimerProps) {
// Format time as M:SS or H:MM:SS
const formatTime = (seconds: number): string => {
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")}`;
};
// Determine color based on time remaining
const getColorClass = (): string => {
if (timeRemaining > 60) {
return "text-green-600 dark:text-green-400";
} else if (timeRemaining > 30) {
return "text-yellow-600 dark:text-yellow-400";
} else if (timeRemaining > 10) {
return "text-red-600 dark:text-red-400";
} else {
return "text-red-600 dark:text-red-400 animate-pulse";
}
};
return (
<div className={`text-sm font-mono font-semibold ${getColorClass()} ${isActive ? "font-bold" : ""}`}>
{formatTime(timeRemaining)}
</div>
);
}

View file

@ -1,195 +0,0 @@
import { useState, useMemo } from "react";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Search, ArrowUpDown } from "lucide-react";
interface Participant {
id: string;
name: string;
sport: string;
expectedValue: number;
}
interface DraftPick {
participantId: string;
}
interface PlayerListProps {
participants: Participant[];
draftPicks: DraftPick[];
onAddToQueue?: (participantId: string) => void;
onDraftPlayer?: (participantId: string) => void;
canDraft: boolean;
}
export function PlayerList({
participants,
draftPicks,
onAddToQueue,
onDraftPlayer,
canDraft,
}: PlayerListProps) {
const [searchQuery, setSearchQuery] = useState("");
const [sportFilter, setSportFilter] = useState<string>("all");
const [sortBy, setSortBy] = useState<"ev" | "name">("ev");
// Get unique sports
const sports = useMemo(() => {
const uniqueSports = new Set(participants.map((p) => p.sport));
return Array.from(uniqueSports).sort();
}, [participants]);
// Get drafted participant IDs
const draftedIds = useMemo(() => {
return new Set(draftPicks.map((p) => p.participantId));
}, [draftPicks]);
// Filter and sort participants
const filteredParticipants = useMemo(() => {
let filtered = participants.filter((p) => {
// Filter out drafted players
if (draftedIds.has(p.id)) return false;
// Filter by search query
if (searchQuery && !p.name.toLowerCase().includes(searchQuery.toLowerCase())) {
return false;
}
// Filter by sport
if (sportFilter !== "all" && p.sport !== sportFilter) {
return false;
}
return true;
});
// Sort
if (sortBy === "ev") {
filtered.sort((a, b) => b.expectedValue - a.expectedValue);
} else {
filtered.sort((a, b) => a.name.localeCompare(b.name));
}
return filtered;
}, [participants, draftedIds, searchQuery, sportFilter, sortBy]);
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Filters */}
<div className="space-y-3 mb-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search players..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{/* Sport Filter and Sort */}
<div className="flex gap-2">
<Select value={sportFilter} onValueChange={setSportFilter}>
<SelectTrigger className="flex-1">
<SelectValue placeholder="All Sports" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Sports</SelectItem>
{sports.map((sport) => (
<SelectItem key={sport} value={sport}>
{sport}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() => setSortBy(sortBy === "ev" ? "name" : "ev")}
className="flex items-center gap-2"
>
<ArrowUpDown className="h-4 w-4" />
{sortBy === "ev" ? "By EV" : "By Name"}
</Button>
</div>
</div>
{/* Player Count */}
<div className="text-sm text-muted-foreground mb-3">
{filteredParticipants.length} player{filteredParticipants.length !== 1 ? "s" : ""} available
</div>
{/* Player Table */}
<div className="flex-1 overflow-y-auto border rounded-md">
{filteredParticipants.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No players found
</div>
) : (
<table className="w-full text-sm border-separate border-spacing-0">
<thead className="sticky top-0 bg-muted border-b">
<tr>
<th className="text-left p-2 font-semibold">Player</th>
<th className="text-left p-2 font-semibold">Sport</th>
<th className="text-right p-2 font-semibold">EV</th>
<th className="text-right p-2 font-semibold">Actions</th>
</tr>
</thead>
<tbody>
{filteredParticipants.map((participant) => (
<tr
key={participant.id}
className="border-b last:border-b-0 hover:bg-accent/50 transition-colors"
>
<td className="p-2 font-medium">{participant.name}</td>
<td className="p-2">
<Badge variant="secondary" className="text-xs">
{participant.sport}
</Badge>
</td>
<td className="p-2 text-right text-muted-foreground">
{participant.expectedValue.toFixed(1)}
</td>
<td className="p-2 text-right">
<div className="flex items-center justify-end gap-1">
{onAddToQueue && (
<Button
variant="outline"
size="sm"
onClick={() => onAddToQueue(participant.id)}
className="h-7 px-2 text-xs"
>
Queue
</Button>
)}
{canDraft && onDraftPlayer && (
<Button
variant="default"
size="sm"
onClick={() => onDraftPlayer(participant.id)}
className="h-7 px-2 text-xs"
>
Draft
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}

View file

@ -1,101 +0,0 @@
import { useEffect, useState, useCallback } from "react";
import { io, Socket } from "socket.io-client";
const SOCKET_URL = typeof window !== "undefined"
? window.location.origin
: "http://localhost:3000";
export interface DraftSocketEvents {
// Events emitted by server
"pick-made": (data: any) => void;
"timer-update": (data: any) => void;
"current-pick-changed": (data: any) => void;
"draft-started": (data: any) => void;
"draft-paused": (data: any) => void;
"draft-completed": (data: any) => void;
"queue-updated": (data: any) => void;
"user-joined": (data: any) => void;
}
export function useDraftSocket(seasonId: string | null) {
const [socket, setSocket] = useState<Socket | null>(null);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
if (!seasonId) return;
// Create socket connection
const newSocket = io(SOCKET_URL, {
transports: ["websocket", "polling"],
});
newSocket.on("connect", () => {
console.log("Socket connected:", newSocket.id);
setIsConnected(true);
// Join the draft room
newSocket.emit("join-draft", seasonId);
});
newSocket.on("disconnect", () => {
console.log("Socket disconnected");
setIsConnected(false);
});
newSocket.on("connect_error", (error) => {
console.error("Socket connection error:", error);
setIsConnected(false);
});
setSocket(newSocket);
// Cleanup on unmount
return () => {
if (newSocket) {
newSocket.emit("leave-draft", seasonId);
newSocket.close();
}
};
}, [seasonId]);
// Helper to emit events
const emit = useCallback((event: string, data?: any) => {
if (socket && isConnected) {
socket.emit(event, data);
} else {
console.warn(`Cannot emit ${event}: socket not connected`);
}
}, [socket, isConnected]);
// Helper to listen to events
const on = useCallback(<K extends keyof DraftSocketEvents>(
event: K,
handler: DraftSocketEvents[K]
) => {
if (socket) {
socket.on(event as string, handler as any);
}
}, [socket]);
// Helper to remove event listeners
const off = useCallback(<K extends keyof DraftSocketEvents>(
event: K,
handler?: DraftSocketEvents[K]
) => {
if (socket) {
if (handler) {
socket.off(event as string, handler as any);
} else {
socket.off(event as string);
}
}
}, [socket]);
return {
socket,
isConnected,
emit,
on,
off,
};
}

View file

@ -139,7 +139,6 @@ export async function findSeasonWithSportsSeasons(
sportsSeason: {
with: {
sport: true,
participants: true,
},
},
},

View file

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

View file

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

View file

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

View file

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

View file

@ -1,412 +0,0 @@
import { data, redirect } from "react-router";
import type { LoaderFunctionArgs } from "react-router";
import { getAuth } from "@clerk/react-router/server";
import { useEffect, useState } from "react";
import {
findLeagueById,
findCurrentSeason,
findTeamsBySeasonId,
isCommissioner,
findDraftSlotsBySeasonId,
getDraftPicks,
getSeasonTimers,
findSeasonWithSportsSeasons,
getTeamQueue,
getTeamForPick,
} from "~/models";
import { DraftGrid } from "~/components/draft/DraftGrid";
import { PlayerList } from "~/components/draft/PlayerList";
import { DraftQueue } from "~/components/draft/DraftQueue";
import { CommissionerControls } from "~/components/draft/CommissionerControls";
import { useDraftSocket } from "~/hooks/useDraftSocket";
export async function loader(args: LoaderFunctionArgs) {
const { userId } = await getAuth(args);
if (!userId) {
throw redirect("/");
}
const { leagueId } = args.params;
if (!leagueId) {
throw new Response("League ID is required", { status: 400 });
}
// Get league and season
const league = await findLeagueById(leagueId);
if (!league) {
throw new Response("League not found", { status: 404 });
}
const season = await findCurrentSeason(leagueId);
if (!season) {
throw new Response("No active season found", { status: 404 });
}
// Get season with sports and participants
const seasonWithSports = await findSeasonWithSportsSeasons(season.id);
const participants = seasonWithSports?.seasonSports?.flatMap(
(ss: any) => {
const sportName = ss.sportsSeason?.sport?.name || "Unknown";
return (ss.sportsSeason?.participants || []).map((p: any) => ({
...p,
sport: sportName,
}));
}
) || [];
// Get teams and draft order
const teams = await findTeamsBySeasonId(season.id);
const draftSlots = await findDraftSlotsBySeasonId(season.id);
// Sort teams by draft order
const teamsWithOrder = teams.map((team) => {
const slot = draftSlots.find((s: { teamId: string }) => s.teamId === team.id);
return {
...team,
draftOrder: slot?.draftOrder || 0,
};
}).sort((a, b) => a.draftOrder - b.draftOrder);
// Check user permissions
const isUserCommissioner = await isCommissioner(leagueId, userId);
const userTeam = teams.find((team) => team.ownerId === userId);
// Get draft data
const draftPicks = await getDraftPicks(season.id);
let timers = await getSeasonTimers(season.id);
// If no timers exist, create default timers for display
if (timers.length === 0) {
timers = teams.map((team) => ({
id: team.id,
seasonId: season.id,
teamId: team.id,
timeRemaining: season.draftInitialTime,
createdAt: new Date(),
updatedAt: new Date(),
}));
}
// Get user's queue if they have a team
const queueItems = userTeam ? await getTeamQueue(userTeam.id) : [];
// Calculate current pick info
const totalPicks = season.draftRounds * teams.length;
const currentPickNumber = season.currentPickNumber || 1;
const isDraftComplete = currentPickNumber > totalPicks;
return data({
league,
season,
teams: teamsWithOrder,
draftSlots,
isCommissioner: isUserCommissioner,
userTeam,
draftPicks,
participants,
queueItems,
timers,
currentPickNumber,
totalPicks,
isDraftComplete,
userId,
});
}
export default function DraftRoom({ loaderData }: { loaderData: Awaited<ReturnType<typeof loader>>["data"] }) {
const {
league,
season,
teams,
draftPicks: initialDraftPicks,
participants,
queueItems: initialQueueItems,
timers: initialTimers,
isCommissioner,
userTeam,
currentPickNumber: initialPickNumber,
totalPicks,
isDraftComplete: initialIsDraftComplete,
draftSlots,
} = loaderData;
// Local state for real-time updates
const [draftPicks, setDraftPicks] = useState(initialDraftPicks);
const [queueItems, setQueueItems] = useState(initialQueueItems);
const [timers, setTimers] = useState(initialTimers);
const [currentPickNumber, setCurrentPickNumber] = useState(initialPickNumber);
const [isDraftComplete, setIsDraftComplete] = useState(initialIsDraftComplete);
const [isDraftStarted, setIsDraftStarted] = useState(!!season.draftStartedAt);
const [isDraftPaused, setIsDraftPaused] = useState(season.draftPaused || false);
// Socket connection
const { on, off } = useDraftSocket(season.id);
// Calculate current team
const draftOrder = draftSlots.map((slot: any) => ({
teamId: slot.teamId,
draftOrder: slot.draftOrder,
}));
const currentTeamId = getTeamForPick(currentPickNumber, draftOrder);
const currentTeam = teams.find((t: any) => t.id === currentTeamId);
const currentTeamName = currentTeam?.name || "Unknown";
// Socket event handlers
useEffect(() => {
// Listen for pick made
const handlePickMade = (data: any) => {
setDraftPicks((prev: any) => [...prev, data.pick]);
setCurrentPickNumber(data.nextPickNumber);
setIsDraftComplete(data.isDraftComplete);
};
// Listen for draft started
const handleDraftStarted = (data: any) => {
setIsDraftStarted(true);
setIsDraftPaused(false);
setTimers(data.timers);
};
// Listen for draft paused
const handleDraftPaused = (data: any) => {
setIsDraftPaused(data.isPaused);
};
// Listen for draft completed
const handleDraftCompleted = () => {
setIsDraftComplete(true);
};
// Listen for queue updates
const handleQueueUpdated = (data: any) => {
if (userTeam && data.teamId === userTeam.id) {
setQueueItems(data.queue);
}
};
on("pick-made", handlePickMade);
on("draft-started", handleDraftStarted);
on("draft-paused", handleDraftPaused);
on("draft-completed", handleDraftCompleted);
on("queue-updated", handleQueueUpdated);
return () => {
off("pick-made", handlePickMade);
off("draft-started", handleDraftStarted);
off("draft-paused", handleDraftPaused);
off("draft-completed", handleDraftCompleted);
off("queue-updated", handleQueueUpdated);
};
}, [on, off, userTeam]);
// Commissioner action handlers
const handleStartDraft = async () => {
try {
const formData = new FormData();
formData.append("action", "start-draft");
formData.append("seasonId", season.id);
const response = await fetch("/api/draft-commissioner", {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = await response.json();
console.error("Failed to start draft:", error);
}
} catch (error) {
console.error("Error starting draft:", error);
}
};
const handlePauseDraft = async () => {
try {
const formData = new FormData();
formData.append("action", "pause-draft");
formData.append("seasonId", season.id);
formData.append("pause", "true");
const response = await fetch("/api/draft-commissioner", {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = await response.json();
console.error("Failed to pause draft:", error);
}
} catch (error) {
console.error("Error pausing draft:", error);
}
};
const handleResumeDraft = async () => {
try {
const formData = new FormData();
formData.append("action", "pause-draft");
formData.append("seasonId", season.id);
formData.append("pause", "false");
const response = await fetch("/api/draft-commissioner", {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = await response.json();
console.error("Failed to resume draft:", error);
}
} catch (error) {
console.error("Error resuming draft:", error);
}
};
const handleForcePick = async () => {
if (!currentTeamId) return;
try {
const formData = new FormData();
formData.append("action", "force-pick");
formData.append("seasonId", season.id);
formData.append("teamId", currentTeamId);
const response = await fetch("/api/draft-commissioner", {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = await response.json();
console.error("Failed to force pick:", error);
}
} catch (error) {
console.error("Error forcing pick:", error);
}
};
return (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b bg-card">
<div className="px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{league.name} Draft</h1>
<p className="text-sm text-muted-foreground">
{season.year} Season Pick {currentPickNumber} of {totalPicks}
</p>
</div>
<div className="flex items-center gap-4">
{isCommissioner && (
<span className="rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
Commissioner
</span>
)}
{userTeam && (
<span className="rounded-full bg-secondary px-3 py-1 text-xs font-medium">
{userTeam.name}
</span>
)}
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="px-6 py-6">
{isDraftComplete ? (
<div className="rounded-lg border bg-card p-8 text-center">
<h2 className="text-2xl font-bold">Draft Complete!</h2>
<p className="mt-2 text-muted-foreground">
All picks have been made. The season is ready to begin.
</p>
</div>
) : (
<div className="space-y-6">
{/* Draft Board - Full Width */}
<div className="rounded-lg border bg-card p-6">
<h2 className="mb-4 text-xl font-bold">Draft Board</h2>
<DraftGrid
teams={teams}
draftPicks={draftPicks}
participants={participants}
draftRounds={season.draftRounds}
currentPickNumber={currentPickNumber}
userTeamId={userTeam?.id}
timers={timers}
/>
</div>
{/* 2x2 Grid Below */}
<div className="grid gap-6 md:grid-cols-2">
{/* Available Players */}
<div className="rounded-lg border bg-card p-6 flex flex-col" style={{ height: "600px" }}>
<h2 className="mb-4 text-xl font-bold">Available Players</h2>
<PlayerList
participants={participants}
draftPicks={draftPicks}
canDraft={false}
/>
</div>
{/* Your Queue */}
<div className="rounded-lg border bg-card p-6 flex flex-col" style={{ height: "600px" }}>
<h2 className="mb-4 text-xl font-bold">Your Queue</h2>
<div className="flex-1 overflow-y-auto">
<DraftQueue
queueItems={queueItems}
participants={participants}
/>
</div>
</div>
{/* Draft Timer */}
<div className="rounded-lg border bg-card p-6">
<h2 className="mb-4 text-xl font-bold">Draft Timer</h2>
<div className="flex items-center justify-center py-12 text-muted-foreground">
Timer will be implemented in Phase 8
</div>
</div>
{/* Commissioner Controls */}
{isCommissioner ? (
<div className="rounded-lg border bg-card p-6">
<h2 className="mb-4 text-xl font-bold">Commissioner Controls</h2>
<CommissionerControls
isDraftStarted={isDraftStarted}
isDraftPaused={isDraftPaused}
isDraftComplete={isDraftComplete}
currentTeamName={currentTeamName}
onStartDraft={handleStartDraft}
onPauseDraft={handlePauseDraft}
onResumeDraft={handleResumeDraft}
onForcePick={handleForcePick}
/>
</div>
) : (
<div className="rounded-lg border bg-card p-6">
<h2 className="mb-4 text-xl font-bold">Draft Info</h2>
<div className="space-y-2 text-sm">
<div>
<span className="text-muted-foreground">Current Pick:</span>
<span className="ml-2 font-medium">{currentPickNumber} of {totalPicks}</span>
</div>
<div>
<span className="text-muted-foreground">Draft Rounds:</span>
<span className="ml-2 font-medium">{season.draftRounds}</span>
</div>
<div>
<span className="text-muted-foreground">Current Team:</span>
<span className="ml-2 font-medium">{currentTeamName}</span>
</div>
</div>
</div>
)}
</div>
</div>
)}
</main>
</div>
);
}

View file

@ -372,15 +372,6 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
</>
)}
</CardContent>
{season && (
<CardContent className="pt-0">
<Link to={`/leagues/${league.id}/draft`}>
<Button className="w-full" variant="default">
{season.status === "draft" ? "Go to Draft Room" : "View Draft Room"}
</Button>
</Link>
</CardContent>
)}
</Card>
<Card>

367
package-lock.json generated
View file

@ -34,8 +34,6 @@
"react-day-picker": "^9.11.1",
"react-dom": "^19.1.0",
"react-router": "^7.7.1",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"sonner": "^2.0.7",
"svix": "^1.77.0",
"tailwind-merge": "^3.3.1"
@ -53,7 +51,7 @@
"@types/react-dom": "^19.1.2",
"dotenv-cli": "^8.0.0",
"tailwindcss": "^4.1.4",
"tsx": "^4.20.6",
"tsx": "^4.19.2",
"tw-animate-css": "^1.4.0",
"typescript": "^5.8.3",
"vite": "^6.3.3",
@ -2865,12 +2863,6 @@
"win32"
]
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
@ -3186,15 +3178,6 @@
"@types/node": "*"
}
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -3255,6 +3238,7 @@
"version": "20.19.20",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.20.tgz",
"integrity": "sha512-2Q7WS25j4pS1cS8yw3d6buNCVJukOTeQ39bAnwR6sOJbaxvyCGebzTMypDFN82CxBLnl+lSWVdCCWbRY6y9yZQ==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@ -3426,15 +3410,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"license": "MIT",
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.16",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz",
@ -3760,19 +3735,6 @@
"node": ">=6.6.0"
}
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -4121,125 +4083,6 @@
"node": ">= 0.8"
}
},
"node_modules/engine.io": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz",
"integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/engine.io-client": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/engine.io/node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
@ -5414,15 +5257,6 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -6231,173 +6065,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/socket.io": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
"integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.3.2",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz",
"integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==",
"license": "MIT",
"dependencies": {
"debug": "~4.3.4",
"ws": "~8.17.1"
}
},
"node_modules/socket.io-adapter/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io/node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/socket.io/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/socket.io/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/socket.io/node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
@ -7232,6 +6899,7 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/unpipe": {
@ -8062,35 +7730,6 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View file

@ -6,9 +6,9 @@
"build": "react-router build",
"db:generate": "dotenv -- drizzle-kit generate",
"db:migrate": "dotenv -- drizzle-kit migrate",
"dev": "dotenv -- tsx server/index.ts",
"start": "tsx server/index.ts",
"start:production": "drizzle-kit migrate && rm -rf drizzle drizzle.config.ts database && tsx server/index.ts",
"dev": "dotenv -- node server.js",
"start": "node server.js",
"start:production": "drizzle-kit migrate && rm -rf drizzle drizzle.config.ts database && node server.js",
"typecheck": "react-router typegen && tsc -b"
},
"dependencies": {
@ -40,8 +40,6 @@
"react-day-picker": "^9.11.1",
"react-dom": "^19.1.0",
"react-router": "^7.7.1",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"sonner": "^2.0.7",
"svix": "^1.77.0",
"tailwind-merge": "^3.3.1"
@ -59,7 +57,7 @@
"@types/react-dom": "^19.1.2",
"dotenv-cli": "^8.0.0",
"tailwindcss": "^4.1.4",
"tsx": "^4.20.6",
"tsx": "^4.19.2",
"tw-animate-css": "^1.4.0",
"typescript": "^5.8.3",
"vite": "^6.3.3",

View file

@ -1,6 +1,5 @@
import compression from "compression";
import express from "express";
import { createServer } from "http";
import morgan from "morgan";
// Short-circuit the type-checking of the built output.
@ -9,7 +8,6 @@ const DEVELOPMENT = process.env.NODE_ENV === "development";
const PORT = Number.parseInt(process.env.PORT || "3000");
const app = express();
const httpServer = createServer(app);
app.use(compression());
app.disable("x-powered-by");
@ -44,13 +42,6 @@ if (DEVELOPMENT) {
app.use(await import(BUILD_PATH).then((mod) => mod.app));
}
// Initialize Socket.IO
const socketModule = await import("./socket.js");
const io = socketModule.setupSocketIO(httpServer);
socketModule.setSocketIO(io);
console.log("Socket.IO initialized");
httpServer.listen(PORT, () => {
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
console.log(`Socket.IO is ready for connections`);
});

View file

@ -4,9 +4,9 @@ import express from "express";
import postgres from "postgres";
import { RouterContextProvider } from "react-router";
import { DatabaseContext } from "../database/context.js";
import * as schema from "../database/schema.js";
import { expressValueContext } from "../app/contexts/express.js";
import { DatabaseContext } from "~/database/context";
import * as schema from "~/database/schema";
import { expressValueContext } from "~/contexts/express";
export const app = express();
@ -22,7 +22,7 @@ app.use(
getLoadContext() {
const context = new RouterContextProvider();
context.set(expressValueContext, "Hello from Express");
return context as any;
return context;
},
}),
);

View file

@ -1,75 +0,0 @@
import { Server as HTTPServer } from "http";
import { Server, Socket } from "socket.io";
export type SocketServer = Server;
export type SocketClient = Socket;
export function setupSocketIO(httpServer: HTTPServer): SocketServer {
const io = new Server(httpServer, {
cors: {
origin: process.env.CLIENT_URL || "*",
methods: ["GET", "POST"],
},
});
io.on("connection", (socket: SocketClient) => {
console.log(`Client connected: ${socket.id}`);
// Join a draft room
socket.on("join-draft", (seasonId: string) => {
const roomName = `draft-${seasonId}`;
socket.join(roomName);
console.log(`Socket ${socket.id} joined draft room: ${roomName}`);
// Notify others in the room
socket.to(roomName).emit("user-joined", { socketId: socket.id });
});
// Leave a draft room
socket.on("leave-draft", (seasonId: string) => {
const roomName = `draft-${seasonId}`;
socket.leave(roomName);
console.log(`Socket ${socket.id} left draft room: ${roomName}`);
});
// Draft Actions - Phase 9
// These events will be handled by API routes and then broadcast back via socket
// The client will call API routes which will then use getSocketIO() to broadcast
socket.on("make-pick", (_data) => {
console.log("make-pick event received, should use API route instead");
});
socket.on("commissioner-start-draft", (_data) => {
console.log("commissioner-start-draft event received, should use API route instead");
});
socket.on("commissioner-pause-draft", (_data) => {
console.log("commissioner-pause-draft event received, should use API route instead");
});
socket.on("commissioner-force-pick", (_data) => {
console.log("commissioner-force-pick event received, should use API route instead");
});
// Handle disconnection
socket.on("disconnect", () => {
console.log(`Client disconnected: ${socket.id}`);
});
});
return io;
}
// Export a singleton instance that will be set by the server
let ioInstance: SocketServer | null = null;
export function setSocketIO(io: SocketServer) {
ioInstance = io;
}
export function getSocketIO(): SocketServer {
if (!ioInstance) {
throw new Error("Socket.IO has not been initialized");
}
return ioInstance;
}

7
server/types.d.ts vendored
View file

@ -1,7 +0,0 @@
// Type declarations for server-side modules
declare module "virtual:react-router/server-build" {
import type { ServerBuild } from "@react-router/node";
const serverBuild: ServerBuild;
export = serverBuild;
}

View file

@ -1,12 +1,6 @@
{
"extends": "./tsconfig.json",
"include": [
"server/**/*.ts",
"vite.config.ts",
"database/**/*.ts",
"app/contexts/**/*.ts",
"app/models/**/*.ts"
],
"include": ["server.js", "vite.config.ts"],
"compilerOptions": {
"composite": true,
"strict": true,

View file

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