brackt/plans/draft-room-implementation.md

722 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Draft Room Implementation Plan
## Overview
Build a real-time draft room using Socket.IO for live updates, with snake draft grid, player selection, per-team queue, and chess clock timers.
---
## Phase 1: Database Schema & Models
### 1.1 New Tables
**`draft_picks`**
```sql
- id (uuid, primary key)
- season_id (uuid, references seasons)
- team_id (uuid, references teams)
- participant_id (uuid, references participants)
- pick_number (integer) - overall pick number
- round (integer)
- pick_in_round (integer)
- picked_by_user_id (varchar) - Clerk user ID
- picked_by_type (enum: 'owner', 'commissioner', 'auto') - who made the pick
- time_used (integer) - seconds used for this pick
- created_at (timestamp)
```
**`draft_queue`**
```sql
- id (uuid, primary key)
- season_id (uuid, references seasons)
- team_id (uuid, references teams)
- participant_id (uuid, references participants)
- queue_position (integer)
- created_at (timestamp)
- updated_at (timestamp)
```
**`draft_timers`**
```sql
- id (uuid, primary key)
- season_id (uuid, references seasons)
- team_id (uuid, references teams)
- time_remaining (integer) - seconds remaining
- updated_at (timestamp)
```
### 1.2 Schema Updates
**Update `seasons` table:**
```sql
- draft_initial_time (integer) - seconds, default 120 (2 minutes)
- draft_increment_time (integer) - seconds, default 30
- current_pick_number (integer) - track current pick
- draft_started_at (timestamp)
- draft_paused (boolean, default false)
```
### 1.3 Model Functions
- `createDraftPick()`
- `getDraftPicks(seasonId)`
- `getCurrentPick(seasonId)`
- `addToQueue(teamId, participantId)`
- `removeFromQueue(queueId)`
- `reorderQueue(teamId, participantIds[])`
- `getTeamQueue(teamId)`
- `updateTeamTimer(teamId, timeRemaining)`
- `getTeamTimers(seasonId)`
- `initializeDraftTimers(seasonId)` - set initial time for all teams
- `autoPickForTeam(teamId)` - pick from queue or top EV
---
## Phase 2: League Settings Updates
### 2.1 Add Timer Configuration to Settings Page
- Add fields to "Draft Rounds" card:
- Initial Time (seconds) - default 120
- Increment Time (seconds) - default 30
- Update action handler to save these values
- Show in league info panel
---
## Phase 3: Socket.IO Setup
### Overview
Socket.IO requires access to the HTTP server instance (not just the Express app). The key pattern is:
```javascript
const server = http.createServer(app);
const io = socketIo(server);
server.listen(port); // NOT app.listen()
```
### Architecture Decision
**Option A: Modify server.js (Recommended)**
- Change `app.listen()` to `http.createServer(app)` + `server.listen()`
- Initialize Socket.IO with the HTTP server
- Keep everything in one process
- ✅ Simple, standard pattern
- ✅ Socket.IO and Express share same port
- ✅ Works with React Router v7 SSR
- ❌ Requires modifying server.js
**Option B: Separate Socket Server**
- Run Socket.IO on different port (e.g., 3001)
- Keep server.js untouched
- ✅ No changes to existing server
- ❌ CORS complexity
- ❌ Two processes to manage
- ❌ Different ports for HTTP and WebSocket
**Decision: Use Option A** - Industry standard, simpler deployment, better DX
### 3.1 Server Setup
#### Step 1: Modify server.js to use HTTP server
**File: `server.js`**
Change from:
```javascript
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
```
To:
```javascript
import { createServer } from "http";
const httpServer = createServer(app);
// Import and initialize Socket.IO
const { initializeSocketIO } = await import("./server/socket.js");
initializeSocketIO(httpServer);
httpServer.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});
```
#### Step 2: Create Socket.IO module
**File: `server/socket.js`** (use .js not .ts to avoid bundling issues)
```javascript
import { Server as SocketIOServer } from "socket.io";
let io;
export function initializeSocketIO(httpServer) {
if (io) {
console.log("Socket.IO already initialized");
return io;
}
io = new SocketIOServer(httpServer, {
cors: {
origin: process.env.NODE_ENV === "production"
? process.env.APP_URL
: `http://localhost:${process.env.PORT || 3000}`,
credentials: true,
},
});
io.on("connection", (socket) => {
console.log("Client connected:", socket.id);
// Join draft room
socket.on("join-draft", (seasonId) => {
socket.join(`draft-${seasonId}`);
console.log(`Socket ${socket.id} joined draft-${seasonId}`);
});
// Leave draft room
socket.on("leave-draft", (seasonId) => {
socket.leave(`draft-${seasonId}`);
console.log(`Socket ${socket.id} left draft-${seasonId}`);
});
socket.on("disconnect", () => {
console.log("Client disconnected:", socket.id);
});
});
// Store globally for access in route handlers
global.__socketIO = io;
console.log("Socket.IO initialized");
return io;
}
export function getSocketIO() {
if (!io && !global.__socketIO) {
throw new Error("Socket.IO not initialized");
}
return io || global.__socketIO;
}
```
#### Step 3: Add global type definition
**File: `server/types.d.ts`**
```typescript
import type { Server as SocketIOServer } from "socket.io";
declare global {
var __socketIO: SocketIOServer | undefined;
}
export {};
```
#### Step 4: Update tsconfig.node.json
Add server directory to includes:
```json
{
"include": [
"server.js",
"server/**/*.js",
"server/**/*.ts",
"vite.config.ts"
]
}
```
### 3.2 Socket Events (Server)
**Listen for:**
- `join-draft` - user joins draft room
- `leave-draft` - user leaves draft room
- `make-pick` - user makes a pick (handled via HTTP POST, not socket)
- `add-to-queue` - add player to queue (handled via HTTP POST)
- `remove-from-queue` - remove from queue (handled via HTTP POST)
- `reorder-queue` - reorder queue (handled via HTTP POST)
**Note:** Write operations (picks, queue changes) should use HTTP POST requests for reliability and error handling. Socket.IO is for broadcasting updates to all clients.
**Emit (from server to clients):**
- `pick-made` - broadcast new pick to room
- `timer-update` - broadcast timer updates (every second)
- `current-pick-changed` - notify whose turn it is
- `draft-started` - draft has begun
- `draft-paused` - draft paused/resumed
- `draft-completed` - all picks made
- `queue-updated` - queue changed (only to team owner)
### 3.3 Client Hook
**File: `app/hooks/useDraftSocket.ts`**
```typescript
import { useEffect, useRef, useState } from "react";
import { io, Socket } from "socket.io-client";
export function useDraftSocket(seasonId: string) {
const socketRef = useRef<Socket | null>(null);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
// Connect to Socket.IO server
const socket = io({
path: "/socket.io",
transports: ["websocket", "polling"],
});
socketRef.current = socket;
socket.on("connect", () => {
console.log("Connected to Socket.IO");
setIsConnected(true);
// Join the draft room
socket.emit("join-draft", seasonId);
});
socket.on("disconnect", () => {
console.log("Disconnected from Socket.IO");
setIsConnected(false);
});
// Cleanup on unmount
return () => {
if (socket) {
socket.emit("leave-draft", seasonId);
socket.disconnect();
}
};
}, [seasonId]);
// Helper to subscribe to events
const on = (event: string, callback: (...args: any[]) => void) => {
socketRef.current?.on(event, callback);
};
// Helper to unsubscribe from events
const off = (event: string, callback?: (...args: any[]) => void) => {
socketRef.current?.off(event, callback);
};
return { socket: socketRef.current, isConnected, on, off };
}
```
### 3.4 Using Socket in Route Handlers
When a pick is made via HTTP POST, broadcast to all clients:
**Example in `app/routes/api/draft-pick.ts`:**
```typescript
import { getSocketIO } from "~/server/socket.js";
export async function action({ request }: ActionFunctionArgs) {
// ... validate and create pick ...
const pick = await createDraftPick({...});
// Broadcast to all clients in the draft room
const io = getSocketIO();
io.to(`draft-${seasonId}`).emit("pick-made", {
pick,
nextPickNumber,
isDraftComplete,
});
return json({ success: true, pick });
}
```
### 3.5 Testing Socket.IO
1. Start dev server: `npm run dev`
2. Check console for "Socket.IO initialized"
3. Open browser console on draft page
4. Should see "Connected to Socket.IO"
5. Check server logs for "Client connected: [socket-id]"
### 3.6 Gotchas & Important Notes
#### ⚠️ Gotcha #1: Import path in route handlers
```typescript
// ❌ WRONG - This won't work in React Router v7
import { getSocketIO } from "~/server/socket.js";
// ✅ CORRECT - Use relative path or configure alias
import { getSocketIO } from "../../../server/socket.js";
```
**Why:** React Router v7 runs server code, but `~` alias typically only works for app code. You may need to configure the import path or use relative imports.
#### ⚠️ Gotcha #2: Client hook creates new connection on every render
The current `useDraftSocket` hook will reconnect if `seasonId` changes. This is correct, but be aware:
- Don't call this hook multiple times in the same component tree
- Don't pass a changing `seasonId` unless you want to switch rooms
- Consider memoizing the `seasonId` prop
#### ⚠️ Gotcha #3: Event listener cleanup
```typescript
// ❌ WRONG - Listeners not cleaned up
useEffect(() => {
socket.on("pick-made", handlePickMade);
}, []);
// ✅ CORRECT - Clean up listeners
useEffect(() => {
socket.on("pick-made", handlePickMade);
return () => {
socket.off("pick-made", handlePickMade);
};
}, [handlePickMade]);
```
#### ⚠️ Gotcha #4: Socket.IO path in production
If you deploy behind a reverse proxy (nginx, etc.), you may need to configure the Socket.IO path:
```javascript
// Client
const socket = io({
path: "/socket.io", // Make sure this matches your proxy config
});
// Server (if needed)
const io = new SocketIOServer(httpServer, {
path: "/socket.io",
});
```
#### ⚠️ Gotcha #5: CORS might not be needed
Since Socket.IO runs on the same server/port as your app, CORS might be unnecessary for same-origin requests. You can simplify:
```javascript
// Simpler config if same-origin
const io = new SocketIOServer(httpServer);
```
#### ⚠️ Gotcha #6: Global storage in development
The global `__socketIO` pattern works, but in development with HMR, you might get multiple instances. The `if (io)` check helps, but be aware of potential stale connections during hot reloads.
#### ⚠️ Gotcha #7: TypeScript import in route handlers
```typescript
// This might cause issues because socket.js is JavaScript
import { getSocketIO } from "~/server/socket.js";
// Consider making it TypeScript with proper types
// Or use @ts-ignore if necessary
// @ts-ignore
import { getSocketIO } from "~/server/socket.js";
```
### 3.7 Production Considerations
- Socket.IO will automatically upgrade from polling to WebSocket
- Ensure load balancer supports WebSocket (sticky sessions required!)
- Consider Redis adapter for multi-server deployments (future)
- Monitor connection counts and memory usage
- Set up proper error handling for disconnections
- Consider implementing reconnection logic with exponential backoff
---
## Phase 4: Draft Room Route & Layout
### 4.1 Route Setup
- Create `/app/routes/leagues/$leagueId.draft.tsx`
- Loader:
- Verify user access (team owner or commissioner)
- Load season, teams, draft slots, participants
- Load existing draft picks
- Load user's team queue
- Load timer states
- No action needed (all via Socket.IO)
### 4.2 Layout Structure
```
┌─────────────────────────────────────────────────┐
│ Draft Room Header │
│ League Name | Status | Commissioner Controls │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ Draft Grid (horizontally scrollable) │
│ Team 1 Team 2 Team 3 ... │
│ 5:23 4:12 3:45 ... (timers) │
│ ┌────┐ ┌────┐ ┌────┐ │
│ │1.01│ │1.02│ │1.03│ ... │
│ └────┘ └────┘ └────┘ │
│ ┌────┐ ┌────┐ ┌────┐ │
│ │2.03│ │2.02│ │2.01│ ... (snake) │
│ └────┘ └────┘ └────┘ │
└─────────────────────────────────────────────────┘
┌──────────────────────┬──────────────────────────┐
│ Available Players │ My Queue │
│ [Search: ____] │ 1. Player A │
│ [x] Hide Drafted │ 2. Player B │
│ │ 3. Player C │
│ □ Player 1 - NFL │ [Clear Queue] │
│ EV: 125 │ │
│ □ Player 2 - NBA │ │
│ EV: 118 │ │
│ ⊠ Player 3 - MLB │ │
│ (DRAFTED) │ │
└──────────────────────┴──────────────────────────┘
```
### 4.3 No Navigation Bar
- Create standalone layout without main nav
- Add "Exit Draft Room" button to return to league page
---
## Phase 5: Draft Grid Component
### 5.1 Calculate Snake Order
- Use existing draft slots for team order
- Generate pick order:
- Round 1: slots in order (1→N)
- Round 2: slots reversed (N→1)
- Round 3: slots in order (1→N)
- etc.
### 5.2 Grid Display
- Show team names as column headers
- Show timer under each team name
- Render grid cells with pick numbers (1.01, 1.02, etc.)
- Highlight current pick (border, background color)
- Show drafted participant in completed picks
- Responsive: horizontal scroll on smaller screens
### 5.3 Pick Cell Component
```tsx
<PickCell
pickNumber="1.01"
round={1}
pickInRound={1}
teamId={teamId}
participant={draftedParticipant}
isCurrent={isCurrentPick}
isUserTeam={isUserTeam}
/>
```
---
## Phase 6: Player List Component
### 6.1 Player List Features
- Display all participants from season sports
- Show: "Participant Name - Sport"
- Show expected value
- Sort by: EV (desc), then alphabetical
- Search/filter by name
- Toggle to hide/show drafted players
- Click to add to queue (if not drafted)
- Visual indicator for drafted players
### 6.2 Player Item Component
```tsx
<PlayerItem
participant={participant}
isDrafted={isDrafted}
onAddToQueue={handleAddToQueue}
/>
```
---
## Phase 7: Queue Component
### 7.1 Queue Features
- Show only current user's team queue
- Display in order (1, 2, 3, ...)
- Drag & drop to reorder
- Remove button for each item
- "Clear Queue" button
- Real-time sync via Socket.IO
### 7.2 Queue Actions
- Add player → emit `add-to-queue` → update DB → emit `queue-updated` to team
- Remove → emit `remove-from-queue` → update DB → emit `queue-updated`
- Reorder → emit `reorder-queue` → update DB → emit `queue-updated`
---
## Phase 8: Timer System
### 8.1 Timer Display
- Show under each team name in grid
- Format:
- "M:SS" for times under 1 hour (e.g., "5:23")
- "H:MM:SS" for times 1 hour or more (e.g., "2:15:30")
- Color coding:
- Green: > 60s
- Yellow: 30-60s
- Red: < 30s
- Flashing red: < 10s
### 8.2 Timer Logic (Server-Side)
- When draft starts: initialize all timers with `draft_initial_time`
- During a team's turn:
- Their timer counts down every second
- Other teams' timers remain paused
- When pick is made:
- Stop countdown for current team
- Add `draft_increment_time` to current team's timer
- Move to next pick
- Start next team's timer countdown
- Emit `timer-update` every second to all clients
- When timer reaches 0: trigger `autoPickForTeam()`
### 8.3 Auto-Pick Function
```typescript
async function autoPickForTeam(teamId: string, seasonId: string) {
// 1. Check queue - pick first item
// 2. If queue empty, pick highest EV participant not drafted
// 3. Create draft pick with picked_by_type='auto'
// 4. Emit pick-made event
// 5. Move to next pick
}
```
---
## Phase 9: Draft Actions & Permissions
### 9.1 Making a Pick
- Only current team owner or commissioner can pick
- Click participant in player list (if current turn)
- Validate: participant not already drafted
- Create draft pick record
- Emit `pick-made` to room
- Update current pick
- Update timers
### 9.2 Commissioner Controls
- Start Draft button (if not started)
- Pause/Resume button
- Force Pick button (for current team)
- Show in header, only visible to commissioners
### 9.3 Draft Start Logic
- Can be started manually by commissioner
- Or auto-start when `draftDateTime` is reached (background job?)
- Change season status to "drafting"
- Initialize timers
- Emit `draft-started`
### 9.4 Draft Completion
- When all picks made (picks = teams × rounds):
- Change season status to "active"
- Emit `draft-completed`
- Stop timers
---
## Phase 10: Real-Time Updates
### 10.1 Client State Management
- Use React state for:
- Draft picks array
- Current pick number
- Team timers
- User's queue
- Available participants
- Update state when Socket.IO events received
### 10.2 Optimistic Updates
- When user makes pick: update UI immediately
- If server rejects: revert and show error
- For queue operations: update immediately
---
## Phase 11: Mobile Responsiveness
### 11.1 Mobile Layout Adjustments
- Draft grid: horizontal scroll
- Stack player list and queue vertically
- Collapsible sections for player list/queue
- Larger touch targets for buttons
- Simplified timer display
---
## Implementation Order
1. **Phase 1**: Database schema & migrations
2. **Phase 2**: League settings timer config
3. **Phase 3**: Socket.IO setup (server + client hook)
4. **Phase 4**: Draft room route & basic layout
5. **Phase 5**: Draft grid component (static first)
6. **Phase 6**: Player list component
7. **Phase 7**: Queue component
8. **Phase 8**: Timer system (server-side logic)
9. **Phase 9**: Draft actions & permissions
10. **Phase 10**: Wire up real-time updates
11. **Phase 11**: Mobile polish
---
## Open Questions / Future Considerations
- **Background job for auto-start**: How to trigger draft start at `draftDateTime`? Cron job? Polling?
- **Timer persistence**: What if server restarts during draft? Store timer state in DB?
- **Undo picks**: Not in initial scope, but may want later
- **Draft history/log**: Separate view to see all picks in order?
- **Notifications**: Alert users when it's their turn? (email, push, etc.)
---
## Requirements Summary
### User Requirements
- Route: `/leagues/$leagueId/draft`
- Queue: Per-team, private (stored in database)
- Timer: Chess clock (initial time + increment per pick)
- Real-time: WebSocket (Socket.IO)
- Mobile: Should work on mobile
- Commissioner: Can force picks
- Participant sorting: By EV (desc), then alphabetical
- Search: Filter participants by name
- Toggle: Show/hide drafted participants
- Teams: 6-16 teams typical (12 average)
- Timer config: Configurable per league/season
- Auto-pick: From queue, or top EV if queue empty
- Draft state: "drafting" status
- Start: Auto-start at draft date/time, or manual by commissioner
- Pause/Resume: Commissioner can pause/resume
- Pick log: Track who made each pick (owner/commissioner/auto)
- Grid: Horizontally scrollable
- Player display: "Player Name - Sport"
- Pre-draft: Room viewable before draft starts
- Post-draft: Change status to "active" when complete