From 918e9ff04a9aa7490eb86c5b126708e65a8d6347 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Sat, 25 Oct 2025 22:11:10 -0700 Subject: [PATCH] feat: Implement autodraft chain logic and timer initialization for next team picks --- app/models/draft-utils.ts | 153 ++++++++++++++++++++-- app/routes/api/draft.force-manual-pick.ts | 100 ++++++++++++-- app/routes/api/draft.make-pick.ts | 88 ++++++++++++- 3 files changed, 314 insertions(+), 27 deletions(-) diff --git a/app/models/draft-utils.ts b/app/models/draft-utils.ts index aae3898..fa48671 100644 --- a/app/models/draft-utils.ts +++ b/app/models/draft-utils.ts @@ -7,6 +7,64 @@ import { getParticipantsForSeasonWithSports } from "./participant"; import { getSeasonSportsSimple } from "./season-sport"; import { calculateDraftEligibility } from "~/lib/draft-eligibility"; +/** + * Check if the next team has autodraft enabled and immediately execute their pick + * This is called after a pick is made to chain autodraft picks + */ +export async function checkAndTriggerNextAutodraft(params: { + seasonId: string; + nextPickNumber: number; + totalTeams: number; + draftSlots: any[]; + db?: ReturnType; +}): Promise { + const { seasonId, nextPickNumber, totalTeams, draftSlots, db: providedDb } = params; + const db = providedDb || database(); + + // Calculate which team is next using snake draft logic + const nextRound = Math.ceil(nextPickNumber / totalTeams); + const isNextRoundEven = nextRound % 2 === 0; + let nextPickInRound = ((nextPickNumber - 1) % totalTeams) + 1; + + // Apply snake draft reversal for even rounds + if (isNextRoundEven) { + nextPickInRound = totalTeams - nextPickInRound + 1; + } + + const nextDraftSlot = draftSlots.find((slot) => slot.draftOrder === nextPickInRound); + if (!nextDraftSlot) return; + + const nextTeamId = nextDraftSlot.teamId; + + // Check if next team has autodraft enabled + const autodraftSettings = await db.query.autodraftSettings.findFirst({ + where: and( + eq(schema.autodraftSettings.seasonId, seasonId), + eq(schema.autodraftSettings.teamId, nextTeamId) + ), + }); + + if (autodraftSettings?.isEnabled) { + console.log( + `[AutodraftChain] Team ${nextTeamId} has autodraft enabled, triggering immediate pick for pick ${nextPickNumber}` + ); + + // Immediately execute autopick for this team + await executeAutoPick({ + seasonId, + teamId: nextTeamId, + pickNumber: nextPickNumber, + triggeredBy: "timer", // Use "timer" to indicate automatic (not commissioner-forced) + autodraftSettings, + db, + }); + } else { + console.log( + `[AutodraftChain] Team ${nextTeamId} does not have autodraft enabled, waiting for manual pick` + ); + } +} + /** * Auto-pick for a team when their timer runs out * 1. Check queue - pick first eligible item if available (cleans up ineligible items) @@ -449,19 +507,11 @@ export async function executeAutoPick(params: { `[AutoPick] Pick created - ${triggeredBy} triggered - Pick ${pickNumber} - Participant ${participantId}` ); - // Update season's current pick number and check if draft complete + // Calculate next pick info (before updating season) const nextPickNumber = pickNumber + 1; const totalPicks = totalTeams * season.draftRounds; const isDraftComplete = nextPickNumber > totalPicks; - await db - .update(schema.seasons) - .set({ - currentPickNumber: isDraftComplete ? pickNumber : nextPickNumber, - status: isDraftComplete ? "active" : season.status, - }) - .where(eq(schema.seasons.id, seasonId)); - // Add increment to the team that just picked const currentTimer = await db.query.draftTimers.findFirst({ where: and( @@ -497,6 +547,80 @@ export async function executeAutoPick(params: { } } + // Initialize timer for the next team BEFORE updating pick number (prevents race condition) + if (!isDraftComplete) { + // Calculate which team is next using snake draft logic + const nextRound = Math.ceil(nextPickNumber / totalTeams); + const isNextRoundEven = nextRound % 2 === 0; + let nextPickInRound = ((nextPickNumber - 1) % totalTeams) + 1; + + // Apply snake draft reversal for even rounds + if (isNextRoundEven) { + nextPickInRound = totalTeams - nextPickInRound + 1; + } + + const nextDraftSlot = draftSlots.find((slot) => slot.draftOrder === nextPickInRound); + + if (nextDraftSlot) { + const nextTeamId = nextDraftSlot.teamId; + const initialTime = season.draftInitialTime || 120; + + console.log( + `[AutoPick] Initializing timer for next team ${nextTeamId} with ${initialTime}s (pick ${nextPickNumber})` + ); + + // Check if timer already exists for next team + const nextTimer = await db.query.draftTimers.findFirst({ + where: and( + eq(schema.draftTimers.seasonId, seasonId), + eq(schema.draftTimers.teamId, nextTeamId) + ), + }); + + if (nextTimer) { + // Update existing timer + await db + .update(schema.draftTimers) + .set({ + timeRemaining: initialTime, + updatedAt: new Date(), + }) + .where(eq(schema.draftTimers.id, nextTimer.id)); + } else { + // Create new timer if it doesn't exist + await db.insert(schema.draftTimers).values({ + seasonId, + teamId: nextTeamId, + timeRemaining: initialTime, + }); + } + + // Emit timer update for next team + try { + const io = (global as any).__socketIO; + if (io) { + io.to(`draft-${seasonId}`).emit("timer-update", { + seasonId, + teamId: nextTeamId, + timeRemaining: initialTime, + currentPickNumber: nextPickNumber, + }); + } + } catch (error) { + console.error("[AutoPick] Socket.IO next timer-update error:", error); + } + } + } + + // Update season's current pick number (AFTER initializing next timer to prevent race condition) + await db + .update(schema.seasons) + .set({ + currentPickNumber: isDraftComplete ? pickNumber : nextPickNumber, + status: isDraftComplete ? "active" : season.status, + }) + .where(eq(schema.seasons.id, seasonId)); + // Remove from ALL team queues in this season (participant is now drafted) await db .delete(schema.draftQueue) @@ -568,6 +692,17 @@ export async function executeAutoPick(params: { console.error("[AutoPick] Socket.IO events error:", error); } + // Check if next team has autodraft enabled and trigger immediately + if (!isDraftComplete) { + await checkAndTriggerNextAutodraft({ + seasonId, + nextPickNumber, + totalTeams, + draftSlots, + db, + }); + } + return { success: true, pick: draftPick, diff --git a/app/routes/api/draft.force-manual-pick.ts b/app/routes/api/draft.force-manual-pick.ts index 83ea7db..dd13424 100644 --- a/app/routes/api/draft.force-manual-pick.ts +++ b/app/routes/api/draft.force-manual-pick.ts @@ -130,19 +130,11 @@ export async function action(args: any) { }) .returning(); - // Update season's current pick number + // Calculate next pick info (before updating season) const nextPickNumber = pickNumber + 1; const totalPicks = totalTeams * season.draftRounds; const isDraftComplete = nextPickNumber > totalPicks; - await db - .update(schema.seasons) - .set({ - currentPickNumber: isDraftComplete ? pickNumber : nextPickNumber, - status: isDraftComplete ? "active" : season.status, - }) - .where(eq(schema.seasons.id, seasonId)); - // Add increment to the team that just picked const currentTimer = await db.query.draftTimers.findFirst({ where: and( @@ -176,6 +168,78 @@ export async function action(args: any) { } } + // Initialize timer for the next team BEFORE updating pick number (prevents race condition) + if (!isDraftComplete) { + // Calculate which team is next using snake draft logic + const nextRound = Math.ceil(nextPickNumber / totalTeams); + const isNextRoundEven = nextRound % 2 === 0; + let nextPickInRound = ((nextPickNumber - 1) % totalTeams) + 1; + + // Apply snake draft reversal for even rounds + if (isNextRoundEven) { + nextPickInRound = totalTeams - nextPickInRound + 1; + } + + const nextDraftSlot = draftSlots.find((slot) => slot.draftOrder === nextPickInRound); + + if (nextDraftSlot) { + const nextTeamId = nextDraftSlot.teamId; + const initialTime = season.draftInitialTime || 120; + + console.log( + `[ForceManualPick] Initializing timer for next team ${nextTeamId} with ${initialTime}s (pick ${nextPickNumber})` + ); + + // Check if timer already exists for next team + const nextTimer = await db.query.draftTimers.findFirst({ + where: and( + eq(schema.draftTimers.seasonId, seasonId), + eq(schema.draftTimers.teamId, nextTeamId) + ), + }); + + if (nextTimer) { + // Update existing timer + await db + .update(schema.draftTimers) + .set({ + timeRemaining: initialTime, + updatedAt: new Date(), + }) + .where(eq(schema.draftTimers.id, nextTimer.id)); + } else { + // Create new timer if it doesn't exist + await db.insert(schema.draftTimers).values({ + seasonId, + teamId: nextTeamId, + timeRemaining: initialTime, + }); + } + + // Emit timer update for next team + try { + const io = (global as any).__socketIO; + io.to(`draft-${seasonId}`).emit("timer-update", { + seasonId, + teamId: nextTeamId, + timeRemaining: initialTime, + currentPickNumber: nextPickNumber, + }); + } catch (error) { + console.error("Socket.IO next timer-update error:", error); + } + } + } + + // Update season's current pick number (AFTER initializing next timer to prevent race condition) + await db + .update(schema.seasons) + .set({ + currentPickNumber: isDraftComplete ? pickNumber : nextPickNumber, + status: isDraftComplete ? "active" : season.status, + }) + .where(eq(schema.seasons.id, seasonId)); + // Remove from ALL team queues in this season (participant is now drafted) await db .delete(schema.draftQueue) @@ -200,7 +264,7 @@ export async function action(args: any) { try { const io = (global as any).__socketIO; const team = draftSlots.find((slot) => slot.team.id === teamId)?.team; - + io.to(`draft-${seasonId}`).emit("pick-made", { pick: { ...draftPick, @@ -222,8 +286,20 @@ export async function action(args: any) { console.error("Socket.IO error:", error); } - return Response.json({ - success: true, + // Check if next team has autodraft enabled and trigger immediately + if (!isDraftComplete) { + const { checkAndTriggerNextAutodraft } = await import("~/models/draft-utils"); + await checkAndTriggerNextAutodraft({ + seasonId, + nextPickNumber, + totalTeams, + draftSlots, + db, + }); + } + + return Response.json({ + success: true, pick: draftPick, nextPickNumber: isDraftComplete ? pickNumber : nextPickNumber, isDraftComplete, diff --git a/app/routes/api/draft.make-pick.ts b/app/routes/api/draft.make-pick.ts index ace8453..09b877b 100644 --- a/app/routes/api/draft.make-pick.ts +++ b/app/routes/api/draft.make-pick.ts @@ -161,6 +161,11 @@ export async function action(args: any) { console.error("Socket.IO participant-removed-from-queues error:", error); } + // Calculate next pick info (before updating season) + const nextPickNumber = currentPickNumber + 1; + const totalPicks = totalTeams * season.draftRounds; + const isDraftComplete = nextPickNumber > totalPicks; + // Add increment to the team that just picked const currentTimer = await db.query.draftTimers.findFirst({ where: and( @@ -194,11 +199,70 @@ export async function action(args: any) { } } - // Update season's current pick number - const nextPickNumber = currentPickNumber + 1; - const totalPicks = totalTeams * season.draftRounds; - const isDraftComplete = nextPickNumber > totalPicks; + // Initialize timer for the next team BEFORE updating pick number (prevents race condition) + if (!isDraftComplete) { + // Calculate which team is next using snake draft logic + const nextRound = Math.ceil(nextPickNumber / totalTeams); + const isNextRoundEven = nextRound % 2 === 0; + let nextPickInRound = ((nextPickNumber - 1) % totalTeams) + 1; + // Apply snake draft reversal for even rounds + if (isSnakeDraft && isNextRoundEven) { + nextPickInRound = totalTeams - nextPickInRound + 1; + } + + const nextDraftSlot = draftSlots.find((slot) => slot.draftOrder === nextPickInRound); + + if (nextDraftSlot) { + const nextTeamId = nextDraftSlot.teamId; + const initialTime = season.draftInitialTime || 120; + + console.log( + `[MakePick] Initializing timer for next team ${nextTeamId} with ${initialTime}s (pick ${nextPickNumber})` + ); + + // Check if timer already exists for next team + const nextTimer = await db.query.draftTimers.findFirst({ + where: and( + eq(schema.draftTimers.seasonId, seasonId), + eq(schema.draftTimers.teamId, nextTeamId) + ), + }); + + if (nextTimer) { + // Update existing timer + await db + .update(schema.draftTimers) + .set({ + timeRemaining: initialTime, + updatedAt: new Date(), + }) + .where(eq(schema.draftTimers.id, nextTimer.id)); + } else { + // Create new timer if it doesn't exist + await db.insert(schema.draftTimers).values({ + seasonId, + teamId: nextTeamId, + timeRemaining: initialTime, + }); + } + + // Emit timer update for next team + try { + const io = (global as any).__socketIO; + io.to(`draft-${seasonId}`).emit("timer-update", { + seasonId, + teamId: nextTeamId, + timeRemaining: initialTime, + currentPickNumber: nextPickNumber, + }); + } catch (error) { + console.error("Socket.IO next timer-update error:", error); + } + } + } + + // Update season's current pick number (AFTER initializing next timer to prevent race condition) await db .update(schema.seasons) .set({ @@ -231,8 +295,20 @@ export async function action(args: any) { console.error("Socket.IO error:", error); } - return Response.json({ - success: true, + // Check if next team has autodraft enabled and trigger immediately + if (!isDraftComplete) { + const { checkAndTriggerNextAutodraft } = await import("~/models/draft-utils"); + await checkAndTriggerNextAutodraft({ + seasonId, + nextPickNumber, + totalTeams, + draftSlots, + db, + }); + } + + return Response.json({ + success: true, pick: draftPick, nextPickNumber: isDraftComplete ? currentPickNumber : nextPickNumber, isDraftComplete,