diff --git a/app/routes/api/draft.force-autopick.ts b/app/routes/api/draft.force-autopick.ts index 996ff7a..fdaebe4 100644 --- a/app/routes/api/draft.force-autopick.ts +++ b/app/routes/api/draft.force-autopick.ts @@ -169,18 +169,49 @@ export async function action(args: any) { }) .where(eq(schema.seasons.id, seasonId)); - // Remove from queue if it was picked from queue - if (teamQueue.some((item) => item.participantId === participantToPick.id)) { + // Add increment to the team that just picked + const currentTimer = await db.query.draftTimers.findFirst({ + where: and( + eq(schema.draftTimers.seasonId, seasonId), + eq(schema.draftTimers.teamId, teamId) + ), + }); + + if (currentTimer) { + const incrementTime = season.draftIncrementTime || 30; + const newTimeRemaining = currentTimer.timeRemaining + incrementTime; await db - .delete(schema.draftQueue) - .where( - and( - eq(schema.draftQueue.teamId, teamId), - eq(schema.draftQueue.participantId, participantToPick.id) - ) - ); + .update(schema.draftTimers) + .set({ + timeRemaining: newTimeRemaining, + updatedAt: new Date(), + }) + .where(eq(schema.draftTimers.id, currentTimer.id)); + + // Emit timer update to all clients + try { + const io = (global as any).__socketIO; + io.to(`draft-${seasonId}`).emit("timer-update", { + seasonId, + teamId, + timeRemaining: newTimeRemaining, + currentPickNumber: pickNumber, + }); + } catch (error) { + console.error("Socket.IO timer-update error:", error); + } } + // Remove from ALL team queues in this season (participant is now drafted) + await db + .delete(schema.draftQueue) + .where( + and( + eq(schema.draftQueue.seasonId, seasonId), + eq(schema.draftQueue.participantId, participantToPick.id) + ) + ); + // Emit socket event try { const io = (global as any).__socketIO; diff --git a/app/routes/api/draft.force-manual-pick.ts b/app/routes/api/draft.force-manual-pick.ts index 8ee5473..32a77e5 100644 --- a/app/routes/api/draft.force-manual-pick.ts +++ b/app/routes/api/draft.force-manual-pick.ts @@ -114,6 +114,49 @@ export async function action(args: any) { }) .where(eq(schema.seasons.id, seasonId)); + // Add increment to the team that just picked + const currentTimer = await db.query.draftTimers.findFirst({ + where: and( + eq(schema.draftTimers.seasonId, seasonId), + eq(schema.draftTimers.teamId, teamId) + ), + }); + + if (currentTimer) { + const incrementTime = season.draftIncrementTime || 30; + const newTimeRemaining = currentTimer.timeRemaining + incrementTime; + await db + .update(schema.draftTimers) + .set({ + timeRemaining: newTimeRemaining, + updatedAt: new Date(), + }) + .where(eq(schema.draftTimers.id, currentTimer.id)); + + // Emit timer update to all clients + try { + const io = (global as any).__socketIO; + io.to(`draft-${seasonId}`).emit("timer-update", { + seasonId, + teamId, + timeRemaining: newTimeRemaining, + currentPickNumber: pickNumber, + }); + } catch (error) { + console.error("Socket.IO timer-update error:", error); + } + } + + // Remove from ALL team queues in this season (participant is now drafted) + await db + .delete(schema.draftQueue) + .where( + and( + eq(schema.draftQueue.seasonId, seasonId), + eq(schema.draftQueue.participantId, participantId) + ) + ); + // Emit socket event try { const io = (global as any).__socketIO; diff --git a/app/routes/api/draft.make-pick.ts b/app/routes/api/draft.make-pick.ts index 4e803c9..de6185d 100644 --- a/app/routes/api/draft.make-pick.ts +++ b/app/routes/api/draft.make-pick.ts @@ -112,6 +112,49 @@ export async function action(args: any) { }) .returning(); + // Remove from ALL team queues in this season (participant is now drafted) + await db + .delete(schema.draftQueue) + .where( + and( + eq(schema.draftQueue.seasonId, seasonId), + eq(schema.draftQueue.participantId, participantId) + ) + ); + + // Add increment to the team that just picked + const currentTimer = await db.query.draftTimers.findFirst({ + where: and( + eq(schema.draftTimers.seasonId, seasonId), + eq(schema.draftTimers.teamId, currentDraftSlot.teamId) + ), + }); + + if (currentTimer) { + const incrementTime = season.draftIncrementTime || 30; + const newTimeRemaining = currentTimer.timeRemaining + incrementTime; + await db + .update(schema.draftTimers) + .set({ + timeRemaining: newTimeRemaining, + updatedAt: new Date(), + }) + .where(eq(schema.draftTimers.id, currentTimer.id)); + + // Emit timer update to all clients + try { + const io = (global as any).__socketIO; + io.to(`draft-${seasonId}`).emit("timer-update", { + seasonId, + teamId: currentDraftSlot.teamId, + timeRemaining: newTimeRemaining, + currentPickNumber, + }); + } catch (error) { + console.error("Socket.IO timer-update error:", error); + } + } + // Update season's current pick number const nextPickNumber = currentPickNumber + 1; const totalPicks = totalTeams * season.draftRounds; diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 92b91d5..bd21980 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -145,6 +145,11 @@ export async function loader(args: any) { // Load user's team queue if they have a team const userQueue = userTeam ? await getTeamQueue(userTeam.id) : []; + // Load draft timers for all teams + const timers = await db.query.draftTimers.findMany({ + where: eq(schema.draftTimers.seasonId, seasonId), + }); + return { season, draftSlots, @@ -152,6 +157,7 @@ export async function loader(args: any) { availableParticipants, userTeam, userQueue, + timers, isCommissioner: !!isCommissioner, currentUserId: userId, }; @@ -165,7 +171,9 @@ export default function DraftRoom() { availableParticipants, userTeam, userQueue, + timers, isCommissioner, + currentUserId, } = useLoaderData(); const { isConnected, on, off } = useDraftSocket(season.id); const [picks, setPicks] = useState(draftPicks); @@ -175,6 +183,14 @@ export default function DraftRoom() { const [sportFilter, setSportFilter] = useState("all"); const [queue, setQueue] = useState(userQueue); const [timeRemaining, setTimeRemaining] = useState(null); + const [teamTimers, setTeamTimers] = useState>(() => { + // Initialize with timers from loader data + const timersMap: Record = {}; + timers.forEach((timer: any) => { + timersMap[timer.teamId] = timer.timeRemaining; + }); + return timersMap; + }); const [forcePickDialogOpen, setForcePickDialogOpen] = useState(false); const [selectedPickSlot, setSelectedPickSlot] = useState<{ pickNumber: number; @@ -190,6 +206,13 @@ export default function DraftRoom() { }; const handleTimerUpdate = (data: any) => { + // Update the specific team's timer + setTeamTimers((prev) => ({ + ...prev, + [data.teamId]: data.timeRemaining, + })); + + // Also update current pick timer for backward compatibility if (data.currentPickNumber === currentPick) { setTimeRemaining(data.timeRemaining); } @@ -464,13 +487,6 @@ export default function DraftRoom() { {season.status.replace("_", " ")} - {season.status === "draft" && ( - - ⏱ {formatTime(timeRemaining)} - - )}
@@ -507,16 +523,19 @@ export default function DraftRoom() {
{/* Team Headers */}
- {draftSlots.map((slot) => ( -
-
- {slot.team.name} + {draftSlots.map((slot) => { + const teamTime = teamTimers[slot.team.id]; + return ( +
+
+ {slot.team.name} +
+
+ {formatTime(teamTime)} +
-
- Pick {slot.draftOrder} -
-
- ))} + ); + })}
{/* Draft Grid Rows */} diff --git a/server/socket.ts b/server/socket.ts index 78b60a1..a896641 100644 --- a/server/socket.ts +++ b/server/socket.ts @@ -93,6 +93,14 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer { global.__socketIO = io; console.log("Socket.IO initialized"); + + // Start the draft timer system (async import but don't await) + import("./timer").then(({ startDraftTimerSystem }) => { + startDraftTimerSystem(); + }).catch((error) => { + console.error("Failed to start timer system:", error); + }); + return io; } diff --git a/server/timer.ts b/server/timer.ts new file mode 100644 index 0000000..2541e04 --- /dev/null +++ b/server/timer.ts @@ -0,0 +1,402 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import * as schema from "../database/schema"; +import { eq, and, desc, asc, inArray, notInArray } from "drizzle-orm"; +import { getSocketIO } from "./socket"; + +// Create a dedicated database connection for the timer +const connectionString = process.env.DATABASE_URL; +if (!connectionString) { + throw new Error("DATABASE_URL is required for timer system"); +} +const client = postgres(connectionString); +const db = drizzle(client, { schema }); + +let timerInterval: NodeJS.Timeout | null = null; + +/** + * Start the draft timer system + * Runs every second to update all active draft timers + */ +export function startDraftTimerSystem(): void { + if (timerInterval) { + console.log("[Timer] Timer system already running"); + return; + } + + timerInterval = setInterval(async () => { + try { + await updateDraftTimers(); + } catch (error) { + console.error("[Timer] Error updating draft timers:", error); + } + }, 1000); + + console.log("[Timer] Draft timer system started"); +} + +/** + * Stop the draft timer system + */ +export function stopDraftTimerSystem(): void { + if (timerInterval) { + clearInterval(timerInterval); + timerInterval = null; + console.log("[Timer] Draft timer system stopped"); + } +} + +/** + * Update all active draft timers + * Called every second by the timer interval + */ +async function updateDraftTimers(): Promise { + const io = getSocketIO(); + + // Get all active drafts + const activeDrafts = await db.query.seasons.findMany({ + where: eq(schema.seasons.status, "draft"), + }); + + if (activeDrafts.length === 0) { + return; // No active drafts + } + + console.log(`[Timer] Updating timers for ${activeDrafts.length} active draft(s)`); + + for (const season of activeDrafts) { + // Skip if draft is paused + if (season.draftPaused) { + continue; + } + + const currentPickNumber = season.currentPickNumber || 1; + + // Get draft slots to determine whose turn it is + const draftSlots = await db.query.draftSlots.findMany({ + where: eq(schema.draftSlots.seasonId, season.id), + orderBy: asc(schema.draftSlots.draftOrder), + }); + + const totalTeams = draftSlots.length; + if (totalTeams === 0) continue; + + // Calculate current round and pick + const currentRound = Math.ceil(currentPickNumber / totalTeams); + const isEvenRound = currentRound % 2 === 0; + let pickInRound = ((currentPickNumber - 1) % totalTeams) + 1; + + // Snake draft: reverse order on even rounds + if (isEvenRound) { + pickInRound = totalTeams - pickInRound + 1; + } + + const currentDraftSlot = draftSlots.find( + (slot: any) => slot.draftOrder === pickInRound + ); + + if (!currentDraftSlot) { + continue; + } + + const currentTeamId = currentDraftSlot.teamId; + + // Get current team's timer + const timer = await db.query.draftTimers.findFirst({ + where: and( + eq(schema.draftTimers.seasonId, season.id), + eq(schema.draftTimers.teamId, currentTeamId) + ), + }); + + if (!timer) { + console.warn(`[Timer] No timer found for team ${currentTeamId} in season ${season.id}`); + continue; + } + + // If timer is at 0 or below, trigger auto-pick immediately + if (timer.timeRemaining <= 0) { + console.log( + `[Timer] Timer at 0 for team ${currentTeamId} in season ${season.id}, triggering auto-pick` + ); + await triggerAutoPick(season.id, currentTeamId, currentPickNumber); + continue; // Skip to next draft after triggering pick + } + + // Decrement timer + const newTimeRemaining = timer.timeRemaining - 1; + + console.log( + `[Timer] Season ${season.id}: Team ${currentTeamId} - ${newTimeRemaining}s remaining (pick ${currentPickNumber})` + ); + + // Update timer in database + await db + .update(schema.draftTimers) + .set({ + timeRemaining: newTimeRemaining, + updatedAt: new Date(), + }) + .where(eq(schema.draftTimers.id, timer.id)); + + // Emit timer update to all clients in the draft room + io.to(`draft-${season.id}`).emit("timer-update", { + seasonId: season.id, + teamId: currentTeamId, + timeRemaining: newTimeRemaining, + currentPickNumber, + }); + + // If timer just hit 0, trigger auto-pick + if (newTimeRemaining === 0) { + console.log( + `[Timer] Timer expired for team ${currentTeamId} in season ${season.id}, triggering auto-pick` + ); + await triggerAutoPick(season.id, currentTeamId, currentPickNumber); + } + } +} + +/** + * Trigger an automatic pick when timer expires + */ +async function triggerAutoPick( + seasonId: string, + teamId: string, + pickNumber: number +): Promise { + try { + const io = getSocketIO(); + + // Get season details + const season = await db.query.seasons.findFirst({ + where: eq(schema.seasons.id, seasonId), + }); + + if (!season) { + console.error(`[Timer] Season ${seasonId} not found for auto-pick`); + return; + } + + // Check if pick already made (race condition protection) + const existingPick = await db.query.draftPicks.findFirst({ + where: and( + eq(schema.draftPicks.seasonId, seasonId), + eq(schema.draftPicks.pickNumber, pickNumber) + ), + }); + + if (existingPick) { + console.log(`[Timer] Pick ${pickNumber} already made, skipping auto-pick`); + return; + } + + // Get team's queue + const queueItems = await db.query.draftQueue.findMany({ + where: and( + eq(schema.draftQueue.seasonId, seasonId), + eq(schema.draftQueue.teamId, teamId) + ), + orderBy: asc(schema.draftQueue.queuePosition), + }); + + let participantId: string | null = null; + + // Try to pick from queue first + if (queueItems.length > 0) { + // Get already drafted participant IDs + const draftedPicks = await db.query.draftPicks.findMany({ + where: eq(schema.draftPicks.seasonId, seasonId), + }); + const draftedParticipantIds = draftedPicks.map((p: any) => p.participantId); + + // Find first queued participant that hasn't been drafted + const availableQueueItem = queueItems.find( + (item: any) => !draftedParticipantIds.includes(item.participantId) + ); + + if (availableQueueItem) { + participantId = availableQueueItem.participantId; + console.log(`[Timer] Auto-picking from queue: ${participantId}`); + } + } + + // If no valid queue item, pick highest EV available participant + if (!participantId) { + // Get sports season IDs for this season + const seasonSports = await db.query.seasonTemplateSports.findMany({ + where: eq(schema.seasonTemplateSports.templateId, season.templateId!), + }); + const sportsSeasonIds = seasonSports.map((ss: any) => ss.sportsSeasonId); + + if (sportsSeasonIds.length === 0) { + console.error(`[Timer] No sports seasons found for season ${seasonId}`); + return; + } + + // Get already drafted participant IDs + const draftedPicks = await db.query.draftPicks.findMany({ + where: eq(schema.draftPicks.seasonId, seasonId), + }); + const draftedParticipantIds = draftedPicks.map((p: any) => p.participantId); + + // Query available participants from the sports seasons + const availableParticipants = await db.query.participants.findMany({ + 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), + limit: 1, + }); + + if (availableParticipants.length === 0) { + console.error(`[Timer] No available participants for auto-pick in season ${seasonId}`); + return; + } + + participantId = availableParticipants[0].id; + console.log(`[Timer] Auto-picking highest EV: ${participantId}`); + } + + // Calculate round and pick in round + const draftSlots = await db.query.draftSlots.findMany({ + where: eq(schema.draftSlots.seasonId, seasonId), + orderBy: asc(schema.draftSlots.draftOrder), + }); + const totalTeams = draftSlots.length; + const round = Math.ceil(pickNumber / totalTeams); + const pickInRound = ((pickNumber - 1) % totalTeams) + 1; + + // Create the draft pick + const [createdPick] = await db.insert(schema.draftPicks).values({ + seasonId, + teamId, + participantId, + pickNumber, + round, + pickInRound, + pickedByUserId: "", // Auto-pick has no user + pickedByType: "auto", + timeUsed: season.draftInitialTime || 120, // Used all time + createdAt: new Date(), + }).returning(); + + console.log(`[Timer] Auto-pick created: Pick ${pickNumber} - Participant ${participantId}`); + + // Remove from ALL team queues in this season (participant is now drafted) + await db + .delete(schema.draftQueue) + .where( + and( + eq(schema.draftQueue.seasonId, seasonId), + eq(schema.draftQueue.participantId, participantId) + ) + ); + + // Check if draft is complete + const totalPicks = totalTeams * (season.draftRounds || 1); + const allPicks = await db.query.draftPicks.findMany({ + where: eq(schema.draftPicks.seasonId, seasonId), + }); + const isDraftComplete = allPicks.length >= totalPicks; + const nextPickNumber = isDraftComplete ? pickNumber : pickNumber + 1; + + // Add increment to the team that just picked + const pickingTeamTimer = await db.query.draftTimers.findFirst({ + where: and( + eq(schema.draftTimers.seasonId, seasonId), + eq(schema.draftTimers.teamId, teamId) + ), + }); + + if (pickingTeamTimer) { + const incrementTime = season.draftIncrementTime || 30; + const newTimeRemaining = pickingTeamTimer.timeRemaining + incrementTime; + await db + .update(schema.draftTimers) + .set({ + timeRemaining: newTimeRemaining, + updatedAt: new Date(), + }) + .where(eq(schema.draftTimers.id, pickingTeamTimer.id)); + + // Emit timer update to all clients + io.to(`draft-${seasonId}`).emit("timer-update", { + seasonId, + teamId, + timeRemaining: newTimeRemaining, + currentPickNumber: pickNumber, + }); + } + + if (isDraftComplete) { + // Draft complete + await db + .update(schema.seasons) + .set({ status: "active" }) + .where(eq(schema.seasons.id, seasonId)); + + io.to(`draft-${seasonId}`).emit("draft-completed"); + console.log(`[Timer] Draft completed for season ${seasonId}`); + } else { + // Move to next pick + await db + .update(schema.seasons) + .set({ currentPickNumber: nextPickNumber }) + .where(eq(schema.seasons.id, seasonId)); + } + + // Fetch the complete pick with all relations (same as API routes do) + const completePick = await db + .select({ + id: schema.draftPicks.id, + seasonId: schema.draftPicks.seasonId, + teamId: schema.draftPicks.teamId, + participantId: schema.draftPicks.participantId, + pickNumber: schema.draftPicks.pickNumber, + round: schema.draftPicks.round, + pickInRound: schema.draftPicks.pickInRound, + pickedByUserId: schema.draftPicks.pickedByUserId, + pickedByType: schema.draftPicks.pickedByType, + timeUsed: schema.draftPicks.timeUsed, + createdAt: schema.draftPicks.createdAt, + team: schema.teams, + participant: schema.participants, + sport: schema.sports, + }) + .from(schema.draftPicks) + .innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id)) + .innerJoin(schema.participants, eq(schema.draftPicks.participantId, schema.participants.id)) + .innerJoin(schema.sportsSeasons, eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id)) + .innerJoin(schema.sports, eq(schema.sportsSeasons.sportId, schema.sports.id)) + .where(eq(schema.draftPicks.id, createdPick.id)) + .limit(1); + + if (completePick.length === 0) { + console.error(`[Timer] Could not fetch complete pick data`); + return; + } + + // Emit pick-made event with the same structure as API routes + io.to(`draft-${seasonId}`).emit("pick-made", { + pick: { + ...completePick[0], + participant: { + ...completePick[0].participant, + sport: completePick[0].sport, + }, + }, + nextPickNumber, + isDraftComplete, + }); + + console.log(`[Timer] Auto-pick complete and broadcasted`); + } catch (error) { + console.error("[Timer] Error in triggerAutoPick:", error); + } +}