From 46f8552f60753fdc1c9e217c24aa345fefa2630a Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Fri, 5 Jun 2026 22:42:19 -0700 Subject: [PATCH] Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Broadcast timer-bank-updated after every pick so all connected clients immediately see the updated time bank (was only visible on next timer-pick-started) - Capture pickMadeAt at route entry (before auth/DB overhead) and use Math.ceil so credited seconds always match the client countdown display - Clear picksExpiresAt on every pick so _schedulePickForSeason starts fresh - Hold schedulingInProgress lock for full timer callback to prevent the recovery interval from scheduling a duplicate timeout mid-pick - Fix force-autopick route: call rescheduleTimer so the next team's clock starts immediately instead of waiting for the old timeout to fire naturally - Fix draft.adjust-time-bank for on-clock teams: shift picksExpiresAt by the adjustment and reschedule, so the client countdown updates; block adjustments that would reduce the bank to zero - Add timer-pick-started / timer-overnight-paused / timer-bank-updated socket events with full type definitions; replace dead timer-update event - Fix draft-state-sync to include expiresAt for the active timer and isOvernightPause state so reconnecting clients see accurate countdown and pause banner immediately - Fix room-closure countdown: capture client-side timestamp when draft completes so countdown runs even before the loader revalidates with draftCompletedAt - Run countdown interval at 500ms with Math.ceil to prevent skipped seconds - Add draft-started socket handler to transition pre-draft UI without a refresh - Fix overnight pause: canPick only blocks on commissioner pause, not overnight pause (timer freezes but player can still pick early) - Extract checkOvernightPause to server/overnight-pause-check.ts, breaking the timer↔socket circular import and ensuring the timezone cache is shared and evicted correctly across both callers - Fix PostgreSQL varchar=uuid type mismatch in getTeamTimezone join Co-Authored-By: Claude Sonnet 4.6 --- app/hooks/useDraftSocketEvents.ts | 36 +- .../__tests__/executeAutoPick.timer.test.ts | 32 +- app/models/draft-utils.ts | 129 +++--- app/routes/api/draft.adjust-time-bank.ts | 58 ++- app/routes/api/draft.force-autopick.ts | 9 + app/routes/api/draft.force-manual-pick.ts | 28 +- app/routes/api/draft.make-pick.ts | 36 +- app/routes/api/draft.pause.ts | 8 + app/routes/api/draft.resume.ts | 8 + app/routes/api/draft.rollback.ts | 24 +- app/routes/api/draft.start.ts | 9 + .../leagues/$leagueId.draft.$seasonId.tsx | 32 +- server/overnight-pause-check.ts | 68 +++ server/socket.ts | 34 +- server/timer.ts | 435 +++++++++--------- 15 files changed, 588 insertions(+), 358 deletions(-) create mode 100644 server/overnight-pause-check.ts diff --git a/app/hooks/useDraftSocketEvents.ts b/app/hooks/useDraftSocketEvents.ts index 6a88f46..30f7c88 100644 --- a/app/hooks/useDraftSocketEvents.ts +++ b/app/hooks/useDraftSocketEvents.ts @@ -82,6 +82,8 @@ export function useDraftSocketEvents({ isDraftComplete?: boolean; }; const handlePickMade = (data: PickMadePayload) => { + // Stop any running countdown — timer-pick-started for the next team will restart it. + setPickTimerExpiresAt(null); if (isRevalidatingRef.current) { pendingPicksDuringRevalidationRef.current.push(data.pick); } else { @@ -117,12 +119,17 @@ export function useDraftSocketEvents({ expiresAt: number; timeRemaining: number; }) => { + setCurrentPick(data.pickNumber); setTeamTimers((prev) => ({ ...prev, [data.teamId]: data.timeRemaining })); setPickTimerExpiresAt({ teamId: data.teamId, expiresAt: data.expiresAt }); setIsOvernightPause(false); setOvernightResumesAt(null); }; + const handleTimerBankUpdated = (data: { teamId: string; timeRemaining: number }) => { + setTeamTimers((prev) => ({ ...prev, [data.teamId]: data.timeRemaining })); + }; + const handleTimerOvernightPaused = (data: { seasonId: string; teamId: string; @@ -133,7 +140,10 @@ export function useDraftSocketEvents({ setOvernightResumesAt(data.resumesAtUTC ? new Date(data.resumesAtUTC) : null); }; - const handleDraftPaused = () => setIsPaused(true); + const handleDraftPaused = () => { + setIsPaused(true); + setPickTimerExpiresAt(null); + }; const handleDraftResumed = () => setIsPaused(false); const handleDraftCompleted = () => setIsDraftComplete(true); @@ -208,7 +218,8 @@ export function useDraftSocketEvents({ setPicks((prev) => prev.filter((p) => p.pickNumber < data.pickNumber)); setCurrentPick(data.pickNumber); setIsDraftComplete(false); - setIsPaused(false); + setIsPaused(true); + setPickTimerExpiresAt(null); }; const handleDraftStateSync = (data: { @@ -216,6 +227,8 @@ export function useDraftSocketEvents({ currentPickNumber: number; isPaused: boolean; status: string; + isOvernightPause?: boolean; + overnightResumesAt?: number; timers?: Array<{ teamId: string; timeRemaining: number; expiresAt?: number }>; queue?: QueueItem[]; watchlistParticipantIds?: string[]; @@ -231,12 +244,25 @@ export function useDraftSocketEvents({ data.timers?.forEach((t) => { updated[t.teamId] = t.timeRemaining; }); return updated; }); - // Restore client-side countdown for the active team on reconnect. - const activeTimer = data.timers.find((t) => t.expiresAt !== undefined); + // Restore countdown for the active team. Filter out already-expired timestamps + // so a reconnect after autopick fired doesn't briefly show a stale countdown. + const now = Date.now(); + const activeTimer = data.timers.find((t) => t.expiresAt !== undefined && t.expiresAt > now); if (activeTimer?.expiresAt) { setPickTimerExpiresAt({ teamId: activeTimer.teamId, expiresAt: activeTimer.expiresAt }); + } else { + setPickTimerExpiresAt(null); } } + // Restore overnight-pause state for clients that connect mid-pause. + if (data.isOvernightPause) { + setIsOvernightPause(true); + setOvernightResumesAt(data.overnightResumesAt ? new Date(data.overnightResumesAt) : null); + setPickTimerExpiresAt(null); + } else { + setIsOvernightPause(false); + setOvernightResumesAt(null); + } if (data.queue) { const q = data.queue; setQueue(() => q); @@ -269,6 +295,7 @@ export function useDraftSocketEvents({ }; on("pick-made", handlePickMade as (data: unknown) => void); + on("timer-bank-updated", handleTimerBankUpdated as (data: unknown) => void); on("timer-pick-started", handleTimerPickStarted as (data: unknown) => void); on("timer-overnight-paused", handleTimerOvernightPaused as (data: unknown) => void); on("draft-paused", handleDraftPaused as (data: unknown) => void); @@ -290,6 +317,7 @@ export function useDraftSocketEvents({ return () => { off("pick-made", handlePickMade as (data: unknown) => void); + off("timer-bank-updated", handleTimerBankUpdated as (data: unknown) => void); off("timer-pick-started", handleTimerPickStarted as (data: unknown) => void); off("timer-overnight-paused", handleTimerOvernightPaused as (data: unknown) => void); off("draft-paused", handleDraftPaused as (data: unknown) => void); diff --git a/app/models/__tests__/executeAutoPick.timer.test.ts b/app/models/__tests__/executeAutoPick.timer.test.ts index 593d8bf..db7c34a 100644 --- a/app/models/__tests__/executeAutoPick.timer.test.ts +++ b/app/models/__tests__/executeAutoPick.timer.test.ts @@ -187,12 +187,12 @@ describe("executeAutoPick — timer mode behavior", () => { // ── chess_clock mode ──────────────────────────────────────────────────────── describe("chess_clock mode", () => { - it("emits timer-update with bank + increment after a timer-triggered pick", async () => { + it("writes exactly increment to DB timer when timer expired (timeRemainingAtPick = 0)", async () => { + // Default mock has timeRemaining: 0 and picksExpiresAt: null (expired timer) const mockDb = makeMockDb({ draftTimerMode: "chess_clock", draftIncrementTime: 15 }); - // Timer expired at 0; 0 + 15 = 15 after the increment mockDb.returning - .mockResolvedValueOnce([mockDraftPick]) // insert draft pick - .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 15 }]); // update timer + .mockResolvedValueOnce([mockDraftPick]) + .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 15 }]); await executeAutoPick({ seasonId: SEASON_ID, @@ -203,9 +203,9 @@ describe("executeAutoPick — timer mode behavior", () => { db: mockDb, }); - expect(mockSocketIO.emit).toHaveBeenCalledWith( - "timer-update", - expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 15 }) + // 0 remaining + 15 increment = 15; picksExpiresAt cleared so next turn starts fresh + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ timeRemaining: 15, picksExpiresAt: null, picksStartedAt: null }) ); }); @@ -245,9 +245,9 @@ describe("executeAutoPick — timer mode behavior", () => { db: mockDb, }); - expect(mockSocketIO.emit).toHaveBeenCalledWith( - "timer-update", - expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 }) + // 0 remaining (expired) + 30 increment = 30 + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null }) ); }); @@ -273,7 +273,7 @@ describe("executeAutoPick — timer mode behavior", () => { // ── standard mode ─────────────────────────────────────────────────────────── describe("standard mode", () => { - it("emits timer-update with exactly draftIncrementTime after a timer-triggered pick", async () => { + it("resets timer to exactly draftIncrementTime in DB after a timer-triggered pick", async () => { const mockDb = makeMockDb({ draftTimerMode: "standard", draftIncrementTime: 30 }); mockDb.returning .mockResolvedValueOnce([mockDraftPick]) @@ -288,9 +288,8 @@ describe("executeAutoPick — timer mode behavior", () => { db: mockDb, }); - expect(mockSocketIO.emit).toHaveBeenCalledWith( - "timer-update", - expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 }) + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null }) ); }); @@ -327,9 +326,8 @@ describe("executeAutoPick — timer mode behavior", () => { db: mockDb, }); - expect(mockSocketIO.emit).toHaveBeenCalledWith( - "timer-update", - expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 90 }) + expect(mockDb.set).toHaveBeenCalledWith( + expect.objectContaining({ timeRemaining: 90, picksExpiresAt: null, picksStartedAt: null }) ); }); diff --git a/app/models/draft-utils.ts b/app/models/draft-utils.ts index 053f0ed..bdfccd8 100644 --- a/app/models/draft-utils.ts +++ b/app/models/draft-utils.ts @@ -1,6 +1,6 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; -import { eq, and, notInArray, desc, inArray, sql, asc } from "drizzle-orm"; +import { eq, and, notInArray, desc, inArray, asc } from "drizzle-orm"; import { logger } from "~/lib/logger"; import type { InferSelectModel } from "drizzle-orm"; import { getTeamQueue, getAllQueuesForSeason } from "./draft-queue"; @@ -471,6 +471,7 @@ export async function executeAutoPick(params: { autodraftSettings?: AutodraftSettings | null; db?: ReturnType; chainEnabled?: boolean; // Set to false when called from within the autodraft chain to prevent recursion + pickMadeAt?: number; // ms timestamp; pass from route handler for accurate remaining-time credit }): Promise<{ success: boolean; error?: string; @@ -491,9 +492,13 @@ export async function executeAutoPick(params: { commissionerUserId, autodraftSettings, db: providedDb, + pickMadeAt: callerPickMadeAt, } = params; const db = providedDb || database(); + // Use caller-provided timestamp when available (route handler captures it before auth/DB overhead). + // Fall back to now for timer-triggered paths where the callback fires at the expiry instant. + const pickMadeAt = callerPickMadeAt ?? Date.now(); try { // Race condition protection - check if pick already made @@ -524,6 +529,10 @@ export async function executeAutoPick(params: { }; } + if (season.draftPaused) { + return { success: false, error: "Draft is paused" }; + } + // Get draft slots to calculate round/pickInRound and get all team IDs const draftSlots = await db.query.draftSlots.findMany({ where: eq(schema.draftSlots.seasonId, seasonId), @@ -611,7 +620,8 @@ export async function executeAutoPick(params: { ? (commissionerUserId || "") : ""; - // Fetch current timer before pick so we have the time remaining at decision point + // Fetch current timer before pick so we have the time remaining at decision point. + // Use picksExpiresAt (wall-clock truth) rather than the stale timeRemaining column. const incrementTime = season.draftIncrementTime || 30; const currentTimer = await db.query.draftTimers.findFirst({ where: and( @@ -624,6 +634,17 @@ export async function executeAutoPick(params: { logger.warn(`[AutoPick] No timer found for team ${teamId} in season ${seasonId}`); } + // Three cases: + // - picksExpiresAt set, in the future: pick was made early → credit ceiling of remaining seconds + // (Math.ceil matches the client display, which also uses ceil) + // - picksExpiresAt set, expired: timer fired → 0 (pickMadeAt >= picksExpiresAt for timer path) + // - picksExpiresAt null: timer not running this turn (while_on, bank-depleted) → use timeRemaining + const timeRemainingAtPick = (() => { + if (!currentTimer?.picksExpiresAt) return currentTimer?.timeRemaining ?? 0; + const msRemaining = currentTimer.picksExpiresAt.getTime() - pickMadeAt; + return msRemaining > 0 ? Math.ceil(msRemaining / 1000) : 0; + })(); + // Create the draft pick — use ON CONFLICT DO NOTHING so that concurrent timer // ticks racing to the same pick slot are handled atomically at the DB level // rather than relying on the TOCTOU pre-check above. @@ -638,8 +659,8 @@ export async function executeAutoPick(params: { pickInRound, pickedByUserId, pickedByType: "auto", - // Records the team's bank balance at the moment the pick was made (seconds remaining) - timeUsed: currentTimer ? currentTimer.timeRemaining : undefined, + // Records the team's actual remaining time at the moment the pick was made + timeUsed: currentTimer ? timeRemainingAtPick : undefined, }) .onConflictDoNothing() .returning(); @@ -660,63 +681,32 @@ export async function executeAutoPick(params: { const isDraftComplete = nextPickNumber > totalPicks; // Update the team's timer after the auto-pick. - // Standard mode: reset to the per-pick time (atomic, prevents race with timer loop). - // Chess clock mode: add the increment so the team starts their next turn with some time - // (without this, a single timeout would permanently freeze their bank at 0). - let emitTimeRemaining: number; + // Standard mode: reset to the per-pick time. + // Chess clock: credit actual remaining time + increment. When the timer fired, timeRemainingAtPick + // is 0, so the team gets exactly incrementTime — their bank is not accidentally refilled. + // Also clear picksExpiresAt/picksStartedAt so _schedulePickForSeason starts fresh next turn. + const newTimeRemaining = season.draftTimerMode === "standard" + ? incrementTime + : timeRemainingAtPick + incrementTime; - if (season.draftTimerMode === "standard") { - const [updatedTimer] = await db - .update(schema.draftTimers) - .set({ timeRemaining: sql`${incrementTime}`, updatedAt: new Date() }) - .where( - and( - eq(schema.draftTimers.seasonId, seasonId), - eq(schema.draftTimers.teamId, teamId) - ) - ) - .returning(); - emitTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime; - logger.log( - `[AutoPick] Reset timer for team ${teamId} to ${emitTimeRemaining}s (standard mode)` - ); - } else { - // Chess clock: add the increment (atomic add, same as a manual pick). - const [updatedTimer] = await db - .update(schema.draftTimers) - .set({ - timeRemaining: sql`${schema.draftTimers.timeRemaining} + ${incrementTime}`, - updatedAt: new Date(), - }) - .where( - and( - eq(schema.draftTimers.seasonId, seasonId), - eq(schema.draftTimers.teamId, teamId) - ) - ) - .returning(); - emitTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime; - if (!updatedTimer) { - await db.insert(schema.draftTimers).values({ seasonId, teamId, timeRemaining: emitTimeRemaining }); - } - logger.log( - `[AutoPick] Chess clock auto-pick for team ${teamId}, bank is now ${emitTimeRemaining}s (+${incrementTime}s increment)` - ); + const [updatedTimer] = await db + .update(schema.draftTimers) + .set({ + timeRemaining: newTimeRemaining, + picksExpiresAt: null as Date | null, + picksStartedAt: null as Date | null, + updatedAt: new Date(), + }) + .where(and(eq(schema.draftTimers.seasonId, seasonId), eq(schema.draftTimers.teamId, teamId))) + .returning(); + + if (!updatedTimer) { + await db.insert(schema.draftTimers).values({ seasonId, teamId, timeRemaining: newTimeRemaining }); } - try { - getSocketIO().to(`draft-${seasonId}`).emit("timer-update", { - seasonId, - teamId, - timeRemaining: emitTimeRemaining, - currentPickNumber: nextPickNumber, - overnightPauseActive: false, - }); - } catch (error) { - logger.error("[AutoPick] Socket.IO timer-update error:", error); - } - - // Next team's timer is unchanged — their bank carries forward as-is + logger.log( + `[AutoPick] Timer for team ${teamId}: ${newTimeRemaining}s (${season.draftTimerMode} mode, +${incrementTime}s increment)` + ); // Update season's current pick number await db @@ -808,6 +798,10 @@ export async function executeAutoPick(params: { isDraftComplete, }); + // Emit timer bank update AFTER pick-made so handlePickMade stops the countdown + // interval first — otherwise the interval could tick 0 and overwrite the new bank. + io.to(`draft-${seasonId}`).emit("timer-bank-updated", { teamId, timeRemaining: newTimeRemaining }); + // Emit draft-completed event if applicable if (isDraftComplete) { io.to(`draft-${seasonId}`).emit("draft-completed"); @@ -817,16 +811,15 @@ export async function executeAutoPick(params: { logger.error("[AutoPick] Socket.IO events error:", error); } - // Recompute Brackt EV/VORP after this pick is committed so autopick paths - // (force-autopick, timer, user autodraft) are not one pick behind. - try { - const updates = await runBracktHarvilleForFantasySeason(seasonId, db); - if (updates.length > 0) { - getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates }); - } - } catch (error) { - logger.error("[AutoPick] Error updating Brackt EVs after pick:", error); - } + // Recompute Brackt EV/VORP after this pick is committed. Fire-and-forget so it + // doesn't delay schedulePickForSeason (and therefore timer-pick-started) for the next team. + runBracktHarvilleForFantasySeason(seasonId, db) + .then((updates) => { + if (updates.length > 0) { + getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates }); + } + }) + .catch((error) => logger.error("[AutoPick] Error updating Brackt EVs after pick:", error)); // Announce before triggering the chain so Discord messages arrive in pick-number // order. If the chain ran first, chained picks would announce before this one. diff --git a/app/routes/api/draft.adjust-time-bank.ts b/app/routes/api/draft.adjust-time-bank.ts index 3bf01ab..a928f2d 100644 --- a/app/routes/api/draft.adjust-time-bank.ts +++ b/app/routes/api/draft.adjust-time-bank.ts @@ -5,6 +5,7 @@ import * as schema from "~/database/schema"; import { isCommissioner } from "~/models/commissioner"; import { logCommissionerAction } from "~/models/audit-log"; import { getSocketIO } from "../../../server/socket"; +import { rescheduleTimer } from "../../../server/timer"; import { logger } from "~/lib/logger"; import type { ActionFunctionArgs } from "react-router"; @@ -59,6 +60,7 @@ export async function action(args: ActionFunctionArgs) { ); let newTime: number; + let isOnClock = false; if (!currentTimer) { if (adjustment <= 0) { @@ -71,11 +73,31 @@ export async function action(args: ActionFunctionArgs) { timeRemaining: newTime, }); } else { - newTime = Math.max(0, currentTimer.timeRemaining + adjustment); - await db - .update(schema.draftTimers) - .set({ timeRemaining: newTime, updatedAt: new Date() }) - .where(eq(schema.draftTimers.id, currentTimer.id)); + const now = Date.now(); + if (currentTimer.picksExpiresAt && currentTimer.picksExpiresAt.getTime() > now) { + // Team is on the clock — base the adjustment on the live expiry, not the stale timeRemaining. + isOnClock = true; + const msRemaining = currentTimer.picksExpiresAt.getTime() - now; + const currentRemaining = Math.max(0, Math.ceil(msRemaining / 1000)); + newTime = currentRemaining + adjustment; + if (newTime <= 0) { + return Response.json({ error: "Adjustment would reduce the time bank to zero or below" }, { status: 400 }); + } + const newExpiresAt = new Date(now + newTime * 1000); + await db + .update(schema.draftTimers) + .set({ timeRemaining: newTime, picksExpiresAt: newExpiresAt, updatedAt: new Date() }) + .where(eq(schema.draftTimers.id, currentTimer.id)); + } else { + newTime = currentTimer.timeRemaining + adjustment; + if (newTime <= 0) { + return Response.json({ error: "Adjustment would reduce the time bank to zero or below" }, { status: 400 }); + } + await db + .update(schema.draftTimers) + .set({ timeRemaining: newTime, updatedAt: new Date() }) + .where(eq(schema.draftTimers.id, currentTimer.id)); + } } const team = await db.query.teams.findFirst({ @@ -96,17 +118,21 @@ export async function action(args: ActionFunctionArgs) { }, }); - try { - getSocketIO() - .to(`draft-${seasonId}`) - .emit("timer-update", { - seasonId, - teamId, - timeRemaining: newTime, - currentPickNumber: season.currentPickNumber ?? 1, - }); - } catch (error) { - logger.error("Socket.IO error:", error); + if (isOnClock) { + // Reschedule cancels the old setTimeout and re-reads picksExpiresAt from the DB, + // then emits timer-pick-started with the new expiresAt so all clients update their countdown. + try { + await rescheduleTimer(seasonId); + } catch (err) { + logger.error("[AdjustTimeBank] rescheduleTimer failed:", err); + } + } else { + // Off-clock team: just push the updated bank to all clients. + try { + getSocketIO().to(`draft-${seasonId}`).emit("timer-bank-updated", { teamId, timeRemaining: newTime }); + } catch (error) { + logger.error("Socket.IO error:", error); + } } return Response.json({ success: true, timeRemaining: newTime }); diff --git a/app/routes/api/draft.force-autopick.ts b/app/routes/api/draft.force-autopick.ts index 3f809c9..d3e2152 100644 --- a/app/routes/api/draft.force-autopick.ts +++ b/app/routes/api/draft.force-autopick.ts @@ -5,9 +5,11 @@ import { eq } from "drizzle-orm"; import { executeAutoPick } from "~/models/draft-utils"; import { isCommissioner } from "~/models/commissioner"; import { logCommissionerAction } from "~/models/audit-log"; +import { rescheduleTimer } from "../../../server/timer"; import type { ActionFunctionArgs } from "react-router"; export async function action(args: ActionFunctionArgs) { + const pickMadeAt = Date.now(); // capture before any async work for accurate timer credit const { request } = args; const session = await auth.api.getSession({ headers: args.request.headers }); const userId = session?.user.id ?? null; @@ -49,12 +51,19 @@ export async function action(args: ActionFunctionArgs) { triggeredBy: "commissioner", commissionerUserId: userId, db, + pickMadeAt, }); if (!result.success) { return Response.json({ error: result.error }, { status: 400 }); } + try { + await rescheduleTimer(seasonId); + } catch { + // Non-fatal — recovery interval will pick it up within 30 s + } + const team = await db.query.teams.findFirst({ where: eq(schema.teams.id, teamId), }); diff --git a/app/routes/api/draft.force-manual-pick.ts b/app/routes/api/draft.force-manual-pick.ts index 33c96b9..a21c65c 100644 --- a/app/routes/api/draft.force-manual-pick.ts +++ b/app/routes/api/draft.force-manual-pick.ts @@ -19,6 +19,7 @@ import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server"; import type { ActionFunctionArgs } from "react-router"; export async function action(args: ActionFunctionArgs) { + const pickMadeAt = Date.now(); // capture before any async work for accurate timer credit const { request } = args; const session = await auth.api.getSession({ headers: args.request.headers }); const userId = session?.user.id ?? null; @@ -180,10 +181,11 @@ export async function action(args: ActionFunctionArgs) { const timerSnapshot = await db.query.draftTimers.findFirst({ where: and(eq(schema.draftTimers.seasonId, seasonId), eq(schema.draftTimers.teamId, teamId)), }); - const pickMadeAt = Date.now(); - const timeRemainingAtPick = timerSnapshot?.picksExpiresAt - ? Math.max(0, Math.floor((timerSnapshot.picksExpiresAt.getTime() - pickMadeAt) / 1000)) - : (timerSnapshot?.timeRemaining ?? 0); + const timeRemainingAtPick = (() => { + if (!timerSnapshot?.picksExpiresAt) return timerSnapshot?.timeRemaining ?? 0; + const msRemaining = timerSnapshot.picksExpiresAt.getTime() - pickMadeAt; + return msRemaining > 0 ? Math.ceil(msRemaining / 1000) : 0; + })(); const newTimeRemaining = season.draftTimerMode === "standard" ? incrementTime : timeRemainingAtPick + incrementTime; @@ -246,6 +248,9 @@ export async function action(args: ActionFunctionArgs) { isDraftComplete, }); + // Emit timer bank update AFTER pick-made so handlePickMade stops the countdown first. + io.to(`draft-${seasonId}`).emit("timer-bank-updated", { teamId, timeRemaining: newTimeRemaining }); + if (isDraftComplete) { io.to(`draft-${seasonId}`).emit("draft-completed"); scheduleDraftRoomClosure(seasonId); @@ -290,14 +295,13 @@ export async function action(args: ActionFunctionArgs) { // Check if next team has autodraft enabled and trigger immediately if (!isDraftComplete) { - try { - const updates = await runBracktHarvilleForFantasySeason(seasonId, db); - if (updates.length > 0) { - getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates }); - } - } catch (error) { - logger.error("Brackt EV update after forced manual pick failed:", error); - } + runBracktHarvilleForFantasySeason(seasonId, db) + .then((updates) => { + if (updates.length > 0) { + getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates }); + } + }) + .catch((error) => logger.error("Brackt EV update after forced manual pick failed:", error)); const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) }); if (!freshSeason?.draftPaused) { diff --git a/app/routes/api/draft.make-pick.ts b/app/routes/api/draft.make-pick.ts index 935bfa2..2ddd5de 100644 --- a/app/routes/api/draft.make-pick.ts +++ b/app/routes/api/draft.make-pick.ts @@ -19,6 +19,7 @@ import { enqueuePickNotification } from "~/services/discord"; import type { ActionFunctionArgs } from "react-router"; export async function action(args: ActionFunctionArgs) { + const pickMadeAt = Date.now(); // capture before any async work for accurate timer credit const { request } = args; const session = await auth.api.getSession({ headers: args.request.headers }); const userId = session?.user.id ?? null; @@ -150,10 +151,11 @@ export async function action(args: ActionFunctionArgs) { eq(schema.draftTimers.teamId, currentDraftSlot.teamId) ), }); - const pickMadeAt = Date.now(); - const timeRemainingAtPick = timerSnapshot?.picksExpiresAt - ? Math.max(0, Math.floor((timerSnapshot.picksExpiresAt.getTime() - pickMadeAt) / 1000)) - : (timerSnapshot?.timeRemaining ?? 0); + const timeRemainingAtPick = (() => { + if (!timerSnapshot?.picksExpiresAt) return timerSnapshot?.timeRemaining ?? 0; + const msRemaining = timerSnapshot.picksExpiresAt.getTime() - pickMadeAt; + return msRemaining > 0 ? Math.ceil(msRemaining / 1000) : 0; + })(); // Create the draft pick const [draftPick] = await db @@ -251,9 +253,6 @@ export async function action(args: ActionFunctionArgs) { }); } - // rescheduleTimer (called below after the autodraft chain) will emit timer-pick-started - // for the next team — no need to emit timer-update here. - // Update season's current pick number (AFTER initializing next timer to prevent race condition) await db .update(schema.seasons) @@ -280,6 +279,13 @@ export async function action(args: ActionFunctionArgs) { isDraftComplete, }); + // Emit timer bank update AFTER pick-made so handlePickMade stops the countdown + // interval first — otherwise the interval could tick 0 and overwrite the new bank. + getSocketIO().to(`draft-${seasonId}`).emit("timer-bank-updated", { + teamId: currentDraftSlot.teamId, + timeRemaining: newTimeRemaining, + }); + if (isDraftComplete) { getSocketIO().to(`draft-${seasonId}`).emit("draft-completed"); scheduleDraftRoomClosure(seasonId); @@ -310,14 +316,14 @@ export async function action(args: ActionFunctionArgs) { // Check if next team has autodraft enabled and trigger immediately if (!isDraftComplete) { - try { - const updates = await runBracktHarvilleForFantasySeason(seasonId, db); - if (updates.length > 0) { - getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates }); - } - } catch (error) { - logger.error("Brackt EV update after pick failed:", error); - } + // Fire-and-forget so it doesn't delay rescheduleTimer (and timer-pick-started) for the next team. + runBracktHarvilleForFantasySeason(seasonId, db) + .then((updates) => { + if (updates.length > 0) { + getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates }); + } + }) + .catch((error) => logger.error("Brackt EV update after pick failed:", error)); const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) }); if (!freshSeason?.draftPaused) { diff --git a/app/routes/api/draft.pause.ts b/app/routes/api/draft.pause.ts index 1466603..fe01c51 100644 --- a/app/routes/api/draft.pause.ts +++ b/app/routes/api/draft.pause.ts @@ -5,6 +5,7 @@ import * as schema from "~/database/schema"; import { isCommissioner } from "~/models/commissioner"; import { logCommissionerAction } from "~/models/audit-log"; import { getSocketIO } from "../../../server/socket"; +import { onDraftPaused } from "../../../server/timer"; import { logger } from "~/lib/logger"; import type { ActionFunctionArgs } from "react-router"; @@ -65,6 +66,13 @@ export async function action(args: ActionFunctionArgs) { details: { pickNumber: season.currentPickNumber }, }); + // Cancel the in-memory timeout and snapshot remaining time so resume is accurate. + try { + await onDraftPaused(seasonId); + } catch (err) { + logger.error("[Pause] onDraftPaused failed:", err); + } + // Emit socket event try { getSocketIO().to(`draft-${seasonId}`).emit("draft-paused", { diff --git a/app/routes/api/draft.resume.ts b/app/routes/api/draft.resume.ts index 3dd27a8..5494073 100644 --- a/app/routes/api/draft.resume.ts +++ b/app/routes/api/draft.resume.ts @@ -5,6 +5,7 @@ import * as schema from "~/database/schema"; import { isCommissioner } from "~/models/commissioner"; import { logCommissionerAction } from "~/models/audit-log"; import { getSocketIO } from "../../../server/socket"; +import { rescheduleTimer } from "../../../server/timer"; import { logger } from "~/lib/logger"; import type { ActionFunctionArgs } from "react-router"; @@ -65,6 +66,13 @@ export async function action(args: ActionFunctionArgs) { details: { pickNumber: season.currentPickNumber }, }); + // Restart the timer from where it was frozen at pause time. + try { + await rescheduleTimer(seasonId); + } catch (err) { + logger.error("[Resume] rescheduleTimer failed:", err); + } + // Emit socket event try { getSocketIO().to(`draft-${seasonId}`).emit("draft-resumed", { diff --git a/app/routes/api/draft.rollback.ts b/app/routes/api/draft.rollback.ts index 656b718..3cf02ae 100644 --- a/app/routes/api/draft.rollback.ts +++ b/app/routes/api/draft.rollback.ts @@ -5,6 +5,7 @@ import { eq, and, gte } from "drizzle-orm"; import { isCommissioner } from "~/models/commissioner"; import { logCommissionerAction } from "~/models/audit-log"; import { getSocketIO } from "../../../server/socket"; +import { onDraftRolledBack } from "../../../server/timer"; import { logger } from "~/lib/logger"; import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server"; @@ -77,12 +78,13 @@ export async function action(args: ActionFunctionArgs) { ) ); - // Update season: reset pick number and unpause + // Update season: reset pick number and pause so the commissioner can review + // before picks resume. They must explicitly hit Resume to restart the clock. await db .update(schema.seasons) .set({ currentPickNumber: pickNumber, - draftPaused: false, + draftPaused: true, }) .where(eq(schema.seasons.id, seasonId)); @@ -99,7 +101,10 @@ export async function action(args: ActionFunctionArgs) { }); try { - getSocketIO().to(`draft-${seasonId}`).emit("draft-rolled-back", { + const io = getSocketIO(); + // Roll-back leaves the draft paused so the commissioner reviews before resuming. + io.to(`draft-${seasonId}`).emit("draft-paused", { seasonId, paused: true }); + io.to(`draft-${seasonId}`).emit("draft-rolled-back", { seasonId, pickNumber, teamId: rollbackSlot?.teamId, @@ -117,5 +122,18 @@ export async function action(args: ActionFunctionArgs) { logger.error("[Rollback] Brackt EV update failed:", error); } + // Reset all timer rows to the initial bank and reschedule the clock from scratch. + // Timer rows are not rolled back by the picks delete above, so without this they + // would show banked values from the picks that were just deleted (e.g. 2:08, 2:07). + const initialTime = + season.draftTimerMode === "standard" + ? (season.draftIncrementTime || 30) + : (season.draftInitialTime || 120); + try { + await onDraftRolledBack(seasonId, initialTime); + } catch (err) { + logger.error("[Rollback] onDraftRolledBack failed:", err); + } + return Response.json({ success: true, pickNumber }); } diff --git a/app/routes/api/draft.start.ts b/app/routes/api/draft.start.ts index 9ccdafc..4c567e0 100644 --- a/app/routes/api/draft.start.ts +++ b/app/routes/api/draft.start.ts @@ -6,6 +6,7 @@ import { isCommissioner } from "~/models/commissioner"; import { findUserById, getUserDisplayName } from "~/models/user"; import { getSocketIO } from "../../../server/socket"; import { startDraft } from "~/services/draft-autostart"; +import { rescheduleTimer } from "../../../server/timer"; import type { ActionFunctionArgs } from "react-router"; @@ -58,5 +59,13 @@ export async function action(args: ActionFunctionArgs) { return Response.json({ error: result.error }, { status }); } + // Kick off the first pick's timer immediately rather than waiting for the + // 30-second recovery interval. + try { + await rescheduleTimer(seasonId); + } catch { + // Non-fatal — recovery interval will pick it up within 30 s + } + return Response.json({ success: true }); } diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 5c42323..455ff61 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -304,13 +304,20 @@ export default function DraftRoom() { }, [season.status, season.autoStartDraft, season.draftDateTime, nowForPauseCheck]); const draftBoardUrl = `/leagues/${season.leagueId}/draft-board/${season.id}`; + + // Capture a client-side timestamp the moment isDraftComplete first becomes true. + // When the draft completes via socket event the loader hasn't revalidated yet, + // so season.draftCompletedAt is null and we need a fallback baseline for the countdown. + const draftCompletedAtClientRef = useRef(null); + if (isDraftComplete && draftCompletedAtClientRef.current === null) { + draftCompletedAtClientRef.current = season.draftCompletedAt?.getTime() ?? Date.now(); + } + const roomClosureCountdown = useMemo(() => { if (!isDraftComplete) return null; - const completedAt = season.draftCompletedAt; - if (!completedAt) return 300; - const elapsed = (nowForPauseCheck.getTime() - completedAt.getTime()) / 1000; - const remaining = Math.max(0, 300 - elapsed); - return Math.ceil(remaining); + const baseMs = season.draftCompletedAt?.getTime() ?? draftCompletedAtClientRef.current ?? Date.now(); + const elapsed = (nowForPauseCheck.getTime() - baseMs) / 1000; + return Math.max(0, Math.ceil(300 - elapsed)); }, [isDraftComplete, season.draftCompletedAt, nowForPauseCheck]); useEffect(() => { @@ -497,18 +504,18 @@ export default function DraftRoom() { const [pickTimerExpiresAt, setPickTimerExpiresAt] = useState<{ teamId: string; expiresAt: number } | null>(() => { // Seed from initial loader data if a timer is already running. const active = timers.find((t) => t.picksExpiresAt && t.picksExpiresAt.getTime() > Date.now()); - return active ? { teamId: active.teamId, expiresAt: active.picksExpiresAt!.getTime() } : null; + return active ? { teamId: active.teamId, expiresAt: active.picksExpiresAt?.getTime() ?? 0 } : null; }); useEffect(() => { if (!pickTimerExpiresAt) return; const { teamId, expiresAt } = pickTimerExpiresAt; const tick = () => { - const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)); + const remaining = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)); setTeamTimers((prev) => prev[teamId] === remaining ? prev : { ...prev, [teamId]: remaining }); }; tick(); // immediate update so display is accurate on mount - const id = setInterval(tick, 1_000); + const id = setInterval(tick, 500); return () => clearInterval(id); }, [pickTimerExpiresAt, setTeamTimers]); @@ -550,6 +557,13 @@ export default function DraftRoom() { } }, [sidebarCollapsed]); + // Revalidate loader data when the draft auto-starts so the pre-draft UI transitions without a refresh. + useEffect(() => { + const handleDraftStarted = () => revalidate(); + on("draft-started", handleDraftStarted); + return () => off("draft-started", handleDraftStarted); + }, [on, off, revalidate]); + // Queue handlers const handleAddToQueue = useCallback(async (participantId: string) => { if (!userTeam) return; @@ -1039,7 +1053,7 @@ export default function DraftRoom() { const isMyTurn = !!( userTeam && currentDraftSlot && currentDraftSlot.team.id === userTeam.id ); - const canPick = isMyTurn && season.status === "draft" && !isPaused; // Only team owner on their turn when draft is active + const canPick = isMyTurn && season.status === "draft" && !isPaused; const currentDraftSlotOwnerName = currentDraftSlot ? ownerMap[currentDraftSlot.team.id] : undefined; const currentClockTime = currentDraftSlot ? teamTimers[currentDraftSlot.team.id] : undefined; diff --git a/server/overnight-pause-check.ts b/server/overnight-pause-check.ts new file mode 100644 index 0000000..052f1c8 --- /dev/null +++ b/server/overnight-pause-check.ts @@ -0,0 +1,68 @@ +import * as schema from "~/database/schema"; +import { eq, sql } from "drizzle-orm"; +import type { InferSelectModel } from "drizzle-orm"; +import { isInOvernightWindow, getOvernightResumeUTC } from "~/lib/overnight-pause"; +import { logger } from "./logger"; +import { db } from "./db"; + +// Cached per-season timezone map to avoid a redundant query on every overnight-pause check. +// timer.ts calls evictOvernightPauseCache() when a season leaves active drafting. +const teamTimezoneCache = new Map>(); + +async function getTeamTimezone(seasonId: string, teamId: string): Promise { + let seasonMap = teamTimezoneCache.get(seasonId); + if (!seasonMap) { + try { + const rows = await db + .select({ teamId: schema.teams.id, timezone: schema.users.timezone }) + .from(schema.teams) + .leftJoin(schema.users, sql`${schema.teams.ownerId}::uuid = ${schema.users.id}`) + .where(eq(schema.teams.seasonId, seasonId)); + seasonMap = new Map(rows.map((r) => [r.teamId, r.timezone || null])); + } catch (err) { + logger.error("[OvernightPause] getTeamTimezone failed:", err); + seasonMap = new Map(); + } + teamTimezoneCache.set(seasonId, seasonMap); + } + return seasonMap.get(teamId) ?? null; +} + +export async function checkOvernightPause( + season: InferSelectModel, + currentTeamId: string +): Promise<{ active: boolean; resumesAtUTC?: number }> { + const mode = season.overnightPauseMode; + const start = season.overnightPauseStart; + const end = season.overnightPauseEnd; + + if (mode === "none" || !start || !end) return { active: false }; + + let tz: string | null = null; + if (mode === "league") { + tz = season.overnightPauseTimezone || null; + } else { + tz = await getTeamTimezone(season.id, currentTeamId); + if (!tz) tz = season.overnightPauseTimezone || null; + } + + if (!tz) return { active: false }; + + if (isInOvernightWindow(tz, start, end)) { + const resumesAt = getOvernightResumeUTC(tz, end); + return { active: true, resumesAtUTC: resumesAt.getTime() }; + } + return { active: false }; +} + +export function evictOvernightPauseCache(seasonId: string): void { + teamTimezoneCache.delete(seasonId); +} + +export function overnightPauseCacheKeys(): IterableIterator { + return teamTimezoneCache.keys(); +} + +export function clearAllOvernightPauseCaches(): void { + teamTimezoneCache.clear(); +} diff --git a/server/socket.ts b/server/socket.ts index ff7b7ae..91bdab6 100644 --- a/server/socket.ts +++ b/server/socket.ts @@ -3,6 +3,8 @@ import { Server as SocketIOServer } from "socket.io"; import type { Server as HTTPServer } from "http"; import * as schema from "~/database/schema"; import { eq, and, asc } from "drizzle-orm"; +import { checkOvernightPause } from "./overnight-pause-check"; +import { calculatePickInfo } from "~/models/draft-utils"; import { logger } from "./logger"; import { db } from "./db"; @@ -18,6 +20,7 @@ interface ServerToClientEvents { "draft-started": (data: { seasonId: string; currentPickNumber: number }) => void; "draft-completed": () => void; "draft-room-closed": () => void; + "timer-bank-updated": (data: { teamId: string; timeRemaining: number }) => void; "timer-pick-started": (data: { seasonId: string; teamId: string; @@ -53,6 +56,8 @@ interface ServerToClientEvents { currentPickNumber: number; isPaused: boolean; status: string; + isOvernightPause: boolean; + overnightResumesAt?: number; picks: Array<{ id: string; pickNumber: number; @@ -231,7 +236,7 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer { // tokens or flaky mobile networks. This socket-based sync provides an // additional, more reliable path since the socket is already connected. try { - const [seasonData, picks, timerRows, queueItems, watchlistItems] = await Promise.all([ + const [seasonData, picks, timerRows, draftSlots, queueItems, watchlistItems] = await Promise.all([ db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId), }), @@ -265,6 +270,10 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer { db.query.draftTimers.findMany({ where: eq(schema.draftTimers.seasonId, seasonId), }), + db.query.draftSlots.findMany({ + where: eq(schema.draftSlots.seasonId, seasonId), + orderBy: asc(schema.draftSlots.draftOrder), + }), teamId ? db.query.draftQueue.findMany({ where: and( @@ -285,10 +294,33 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer { ]); if (seasonData) { + // Compute overnight-pause state so reconnecting clients see it immediately. + let isOvernightPause = false; + let overnightResumesAt: number | undefined; + + if ( + draftSlots.length > 0 && + seasonData.status === "draft" && + !seasonData.draftPaused + ) { + const { pickInRound } = calculatePickInfo( + seasonData.currentPickNumber ?? 1, + draftSlots.length + ); + const currentSlot = draftSlots.find((s) => s.draftOrder === pickInRound); + if (currentSlot) { + const result = await checkOvernightPause(seasonData, currentSlot.teamId); + isOvernightPause = result.active; + overnightResumesAt = result.resumesAtUTC; + } + } + socket.emit("draft-state-sync", { currentPickNumber: seasonData.currentPickNumber || 1, isPaused: seasonData.draftPaused || false, status: seasonData.status, + isOvernightPause, + overnightResumesAt, picks, timers: timerRows.map((t) => { const nowMs = Date.now(); diff --git a/server/timer.ts b/server/timer.ts index ef004a6..98f079f 100644 --- a/server/timer.ts +++ b/server/timer.ts @@ -3,7 +3,7 @@ import { eq, and, asc, lte, isNotNull } from "drizzle-orm"; import type { InferSelectModel } from "drizzle-orm"; import { getSocketIO } from "./socket"; import { executeAutoPick, calculatePickInfo } from "~/models/draft-utils"; -import { isInOvernightWindow, getOvernightResumeUTC } from "~/lib/overnight-pause"; +import { checkOvernightPause, evictOvernightPauseCache, overnightPauseCacheKeys, clearAllOvernightPauseCaches } from "./overnight-pause-check"; import { logger } from "./logger"; import { db } from "./db"; import { startDraft } from "~/services/draft-autostart"; @@ -16,57 +16,8 @@ const schedulingInProgress = new Set(); // prevents conc // Draft slots never change during an active draft — cache them to avoid a redundant query. const draftSlotsCache = new Map(); -// Team timezone cache for per_user overnight pause mode (seasonId → teamId → timezone). -const teamTimezoneCache = new Map>(); - let recoveryInterval: NodeJS.Timeout | null = null; -async function getTeamTimezone(seasonId: string, teamId: string): Promise { - let seasonMap = teamTimezoneCache.get(seasonId); - if (!seasonMap) { - try { - const rows = await db - .select({ teamId: schema.teams.id, timezone: schema.users.timezone }) - .from(schema.teams) - .leftJoin(schema.users, eq(schema.teams.ownerId, schema.users.id)) - .where(eq(schema.teams.seasonId, seasonId)); - seasonMap = new Map(rows.map((r) => [r.teamId, r.timezone || null])); - } catch (err) { - logger.error("[Timer] getTeamTimezone failed:", err); - seasonMap = new Map(); - } - teamTimezoneCache.set(seasonId, seasonMap); - } - return seasonMap.get(teamId) ?? null; -} - -async function checkOvernightPause( - season: InferSelectModel, - currentTeamId: string -): Promise<{ active: boolean; resumesAtUTC?: number }> { - const mode = season.overnightPauseMode; - const start = season.overnightPauseStart; - const end = season.overnightPauseEnd; - - if (mode === "none" || !start || !end) return { active: false }; - - let tz: string | null = null; - if (mode === "league") { - tz = season.overnightPauseTimezone || null; - } else { - tz = await getTeamTimezone(season.id, currentTeamId); - if (!tz) tz = season.overnightPauseTimezone || null; - } - - if (!tz) return { active: false }; - - if (isInOvernightWindow(tz, start, end)) { - const resumesAt = getOvernightResumeUTC(tz, end); - return { active: true, resumesAtUTC: resumesAt.getTime() }; - } - return { active: false }; -} - async function getDraftSlotsCached(seasonId: string): Promise<{ teamId: string; draftOrder: number }[]> { let slots = draftSlotsCache.get(seasonId); if (!slots) { @@ -170,121 +121,158 @@ async function triggerAutoPick( } // Core scheduling logic — called after acquiring the schedulingInProgress lock. +// Iterative rather than recursive so while_on chains and bank-depleted runs don't +// build unbounded call-stack depth or bypass the outer lock via re-entry. async function _schedulePickForSeason(seasonId: string): Promise { - const season = await db.query.seasons.findFirst({ - where: eq(schema.seasons.id, seasonId), - }); - if (!season || season.status !== "draft" || season.draftPaused) return; + const MAX_CONSECUTIVE_AUTOPICKS = 100; // safety cap against infinite loops on data bugs - const draftSlots = await getDraftSlotsCached(seasonId); - const totalTeams = draftSlots.length; - if (totalTeams === 0) return; + for (let iteration = 0; iteration < MAX_CONSECUTIVE_AUTOPICKS; iteration++) { + const season = await db.query.seasons.findFirst({ + where: eq(schema.seasons.id, seasonId), + }); + if (!season || season.status !== "draft" || season.draftPaused) return; - const currentPickNumber = season.currentPickNumber ?? 1; - const { pickInRound } = calculatePickInfo(currentPickNumber, totalTeams); - const currentDraftSlot = draftSlots.find((s) => s.draftOrder === pickInRound); - if (!currentDraftSlot) return; + const draftSlots = await getDraftSlotsCached(seasonId); + const totalTeams = draftSlots.length; + if (totalTeams === 0) return; - const currentTeamId = currentDraftSlot.teamId; + const currentPickNumber = season.currentPickNumber ?? 1; + const { pickInRound } = calculatePickInfo(currentPickNumber, totalTeams); + const currentDraftSlot = draftSlots.find((s) => s.draftOrder === pickInRound); + if (!currentDraftSlot) return; - const autodraftSettings = await db.query.autodraftSettings.findFirst({ - where: and( - eq(schema.autodraftSettings.seasonId, seasonId), - eq(schema.autodraftSettings.teamId, currentTeamId) - ), - }); - const shouldAutodraft = autodraftSettings?.isEnabled ?? false; - const isWhileOn = shouldAutodraft && autodraftSettings?.mode === "while_on"; + const currentTeamId = currentDraftSlot.teamId; - if (isWhileOn) { - logger.log(`[Timer] ⚡ while_on autodraft — immediate pick for team ${currentTeamId} in season ${seasonId} (pick ${currentPickNumber})`); - try { - getSocketIO().to(`draft-${seasonId}`).emit("timer-pick-started", { - seasonId, - teamId: currentTeamId, - pickNumber: currentPickNumber, - expiresAt: Date.now(), - timeRemaining: 0, - }); - } catch (_) { /* non-fatal */ } + const autodraftSettings = await db.query.autodraftSettings.findFirst({ + where: and( + eq(schema.autodraftSettings.seasonId, seasonId), + eq(schema.autodraftSettings.teamId, currentTeamId) + ), + }); + const shouldAutodraft = autodraftSettings?.isEnabled ?? false; + const isWhileOn = shouldAutodraft && autodraftSettings?.mode === "while_on"; - const success = await triggerAutoPick(seasonId, currentTeamId, currentPickNumber, autodraftSettings ?? null); - if (!success) { - await pauseDraftOnError(seasonId, currentTeamId); + if (isWhileOn) { + logger.log(`[Timer] ⚡ while_on autodraft — immediate pick for team ${currentTeamId} in season ${seasonId} (pick ${currentPickNumber})`); + try { + getSocketIO().to(`draft-${seasonId}`).emit("timer-pick-started", { + seasonId, + teamId: currentTeamId, + pickNumber: currentPickNumber, + expiresAt: Date.now(), + timeRemaining: 0, + }); + } catch { /* non-fatal */ } + + const success = await triggerAutoPick(seasonId, currentTeamId, currentPickNumber, autodraftSettings ?? null); + if (!success) { await pauseDraftOnError(seasonId, currentTeamId); return; } + continue; // re-read season state — currentPickNumber has advanced + } + + // Overnight pause: freeze timer; schedule a wakeup for when the window ends. + const overnightPause = await checkOvernightPause(season, currentTeamId); + const isOvernightFreeze = overnightPause.active && !shouldAutodraft; + + if (isOvernightFreeze) { + try { + getSocketIO().to(`draft-${seasonId}`).emit("timer-overnight-paused", { + seasonId, + teamId: currentTeamId, + resumesAtUTC: overnightPause.resumesAtUTC, + }); + } catch { /* non-fatal */ } + + if (overnightPause.resumesAtUTC) { + const msUntilResume = Math.max(0, overnightPause.resumesAtUTC - Date.now()); + const resumeTimeout = setTimeout(async () => { + overnightResumeTimeouts.delete(seasonId); + await schedulePickForSeason(seasonId); + }, msUntilResume + 2_000); // 2 s buffer in case clocks drift + overnightResumeTimeouts.set(seasonId, resumeTimeout); + } return; } - // The chain may have advanced currentPickNumber past all while_on teams — reschedule. - await _schedulePickForSeason(seasonId); - return; - } - // Overnight pause: freeze timer; schedule a wakeup for when the window ends. - const overnightPause = await checkOvernightPause(season, currentTeamId); - const isOvernightFreeze = overnightPause.active && !shouldAutodraft; - - if (isOvernightFreeze) { - try { - getSocketIO().to(`draft-${seasonId}`).emit("timer-overnight-paused", { - seasonId, - teamId: currentTeamId, - resumesAtUTC: overnightPause.resumesAtUTC, - }); - } catch (_) { /* non-fatal */ } - - if (overnightPause.resumesAtUTC) { - const msUntilResume = Math.max(0, overnightPause.resumesAtUTC - Date.now()); - const resumeTimeout = setTimeout(async () => { - overnightResumeTimeouts.delete(seasonId); - await schedulePickForSeason(seasonId); - }, msUntilResume + 2_000); // 2 s buffer in case clocks drift - overnightResumeTimeouts.set(seasonId, resumeTimeout); - } - return; - } - - // Get or create timer row for this team. - let timer = await db.query.draftTimers.findFirst({ - where: and( - eq(schema.draftTimers.seasonId, seasonId), - eq(schema.draftTimers.teamId, currentTeamId) - ), - }); - - if (!timer) { - const initialTime = - season.draftTimerMode === "standard" - ? (season.draftIncrementTime || 30) - : (season.draftInitialTime || 120); - const startedAt = new Date(); - const expiresAt = new Date(startedAt.getTime() + initialTime * 1000); - await db.insert(schema.draftTimers).values({ - seasonId, - teamId: currentTeamId, - timeRemaining: initialTime, - picksExpiresAt: expiresAt, - picksStartedAt: startedAt, - }); - // Re-fetch to get the full row (including generated id) - timer = await db.query.draftTimers.findFirst({ + // Get or create timer row for this team. + let timer = await db.query.draftTimers.findFirst({ where: and( eq(schema.draftTimers.seasonId, seasonId), eq(schema.draftTimers.teamId, currentTeamId) ), }); - if (!timer) return; // unexpected - } - const now = Date.now(); - let expiresAt: Date; + if (!timer) { + const initialTime = + season.draftTimerMode === "standard" + ? (season.draftIncrementTime || 30) + : (season.draftInitialTime || 120); + const startedAt = new Date(); + const expiresAt = new Date(startedAt.getTime() + initialTime * 1000); + await db.insert(schema.draftTimers).values({ + seasonId, + teamId: currentTeamId, + timeRemaining: initialTime, + picksExpiresAt: expiresAt, + picksStartedAt: startedAt, + }); + // Re-fetch to get the full row (including generated id) + timer = await db.query.draftTimers.findFirst({ + where: and( + eq(schema.draftTimers.seasonId, seasonId), + eq(schema.draftTimers.teamId, currentTeamId) + ), + }); + if (!timer) return; // unexpected + } - if (timer.picksExpiresAt && timer.picksExpiresAt.getTime() > now) { - // Timer was already running (e.g. process restarted mid-pick). Honour the existing expiry. - expiresAt = timer.picksExpiresAt; - } else { - // Fresh start of this team's turn (normal path after a pick or on startup). - const bank = timer.timeRemaining; - if (bank <= 0) { - // Bank depleted — trigger autopick immediately. + const now = Date.now(); + let expiresAt: Date; + + if (timer.picksExpiresAt && timer.picksExpiresAt.getTime() > now) { + // Timer was already running (e.g. process restarted mid-pick). Honour the existing expiry. + expiresAt = timer.picksExpiresAt; + } else { + // Fresh start of this team's turn (normal path after a pick or on startup). + const bank = timer.timeRemaining; + if (bank <= 0) { + // Bank depleted — trigger autopick immediately and loop to schedule the next team. + const success = await triggerAutoPick( + seasonId, + currentTeamId, + currentPickNumber, + shouldAutodraft ? (autodraftSettings ?? null) : null + ); + if (!success) { await pauseDraftOnError(seasonId, currentTeamId); return; } + continue; + } + expiresAt = new Date(now + bank * 1000); + await db + .update(schema.draftTimers) + .set({ picksExpiresAt: expiresAt, picksStartedAt: new Date() }) + .where( + and( + eq(schema.draftTimers.seasonId, seasonId), + eq(schema.draftTimers.teamId, currentTeamId) + ) + ); + } + + const msUntilExpiry = expiresAt.getTime() - now; + const timeRemaining = Math.max(0, Math.ceil(msUntilExpiry / 1000)); + + // Tell clients to start their local countdown. + try { + getSocketIO().to(`draft-${seasonId}`).emit("timer-pick-started", { + seasonId, + teamId: currentTeamId, + pickNumber: currentPickNumber, + expiresAt: expiresAt.getTime(), + timeRemaining, + }); + } catch { /* non-fatal */ } + + if (msUntilExpiry <= 0) { + // Already expired (e.g. picked up on recovery interval after a long pause). const success = await triggerAutoPick( seasonId, currentTeamId, @@ -292,67 +280,47 @@ async function _schedulePickForSeason(seasonId: string): Promise { shouldAutodraft ? (autodraftSettings ?? null) : null ); if (!success) { await pauseDraftOnError(seasonId, currentTeamId); return; } - await _schedulePickForSeason(seasonId); - return; + continue; } - expiresAt = new Date(now + bank * 1000); - await db - .update(schema.draftTimers) - .set({ picksExpiresAt: expiresAt, picksStartedAt: new Date() }) - .where( - and( - eq(schema.draftTimers.seasonId, seasonId), - eq(schema.draftTimers.teamId, currentTeamId) - ) - ); + + const timeout = setTimeout(async () => { + pickTimeouts.delete(seasonId); + // Hold the schedulingInProgress lock for the entire pick+reschedule sequence. + // Without this, the recovery interval (which checks !pickTimeouts.has) could fire + // between the delete above and the DB commit inside triggerAutoPick, read a stale + // currentPickNumber, and schedule a duplicate timeout for the same pick. + if (schedulingInProgress.has(seasonId)) { + logger.warn(`[Timer] Skipping expired-timer callback for ${seasonId} — scheduling already in progress`); + return; + } + schedulingInProgress.add(seasonId); + try { + logger.log( + `[Timer] ⚠️ Timer expired for team ${currentTeamId} in season ${seasonId} (pick ${currentPickNumber})` + ); + const success = await triggerAutoPick( + seasonId, + currentTeamId, + currentPickNumber, + shouldAutodraft ? (autodraftSettings ?? null) : null + ); + if (!success) { + await pauseDraftOnError(seasonId, currentTeamId); + return; + } + await _schedulePickForSeason(seasonId); + } catch (err) { + logger.error(`[Timer] Error in timer callback for ${seasonId}:`, err); + } finally { + schedulingInProgress.delete(seasonId); + } + }, msUntilExpiry); + + pickTimeouts.set(seasonId, timeout); + return; // timer is set — done until it fires } - const msUntilExpiry = expiresAt.getTime() - now; - const timeRemaining = Math.max(0, Math.ceil(msUntilExpiry / 1000)); - - // Tell clients to start their local countdown. - try { - getSocketIO().to(`draft-${seasonId}`).emit("timer-pick-started", { - seasonId, - teamId: currentTeamId, - pickNumber: currentPickNumber, - expiresAt: expiresAt.getTime(), - timeRemaining, - }); - } catch (_) { /* non-fatal */ } - - if (msUntilExpiry <= 0) { - // Already expired (e.g. picked up on recovery interval after a long pause). - const success = await triggerAutoPick( - seasonId, - currentTeamId, - currentPickNumber, - shouldAutodraft ? (autodraftSettings ?? null) : null - ); - if (!success) { await pauseDraftOnError(seasonId, currentTeamId); return; } - await _schedulePickForSeason(seasonId); - return; - } - - const timeout = setTimeout(async () => { - pickTimeouts.delete(seasonId); - logger.log( - `[Timer] ⚠️ Timer expired for team ${currentTeamId} in season ${seasonId} (pick ${currentPickNumber})` - ); - const success = await triggerAutoPick( - seasonId, - currentTeamId, - currentPickNumber, - shouldAutodraft ? (autodraftSettings ?? null) : null - ); - if (!success) { - await pauseDraftOnError(seasonId, currentTeamId); - return; - } - await schedulePickForSeason(seasonId); - }, msUntilExpiry); - - pickTimeouts.set(seasonId, timeout); + logger.error(`[Timer] Hit max consecutive auto-picks (${MAX_CONSECUTIVE_AUTOPICKS}) for season ${seasonId}`); } async function schedulePickForSeason(seasonId: string): Promise { @@ -375,12 +343,13 @@ async function scheduleAllActiveDrafts(): Promise { }); // Evict caches for seasons no longer drafting. + // Union both cache key sets so overnight-pause entries populated by socket.ts + // (without a corresponding draftSlotsCache entry) are also evicted. const activeIds = new Set(activeDrafts.map((s) => s.id)); - for (const id of draftSlotsCache.keys()) { - if (!activeIds.has(id)) draftSlotsCache.delete(id); - } - for (const id of teamTimezoneCache.keys()) { - if (!activeIds.has(id)) teamTimezoneCache.delete(id); + const staleIds = new Set([...draftSlotsCache.keys(), ...overnightPauseCacheKeys()].filter((id) => !activeIds.has(id))); + for (const id of staleIds) { + draftSlotsCache.delete(id); + evictOvernightPauseCache(id); } for (const season of activeDrafts) { @@ -424,7 +393,7 @@ export function stopDraftTimerSystem(): void { overnightResumeTimeouts.clear(); schedulingInProgress.clear(); draftSlotsCache.clear(); - teamTimezoneCache.clear(); + clearAllOvernightPauseCaches(); logger.log("[Timer] Draft timer system stopped"); } @@ -435,6 +404,46 @@ export function stopDraftTimerSystem(): void { export async function rescheduleTimer(seasonId: string): Promise { cancelPickTimeout(seasonId); cancelOvernightResumeTimeout(seasonId); - draftSlotsCache.delete(seasonId); // pick may have changed the draft order state await schedulePickForSeason(seasonId); } + +/** + * Called by the pause route. Cancels the in-memory timeout and snapshots the + * remaining time into timeRemaining so resume starts from exactly where it left off. + */ +export async function onDraftPaused(seasonId: string): Promise { + cancelPickTimeout(seasonId); + cancelOvernightResumeTimeout(seasonId); + + const now = Date.now(); + const activeTimers = await db.query.draftTimers.findMany({ + where: and( + eq(schema.draftTimers.seasonId, seasonId), + isNotNull(schema.draftTimers.picksExpiresAt), + ), + }); + for (const timer of activeTimers) { + if (!timer.picksExpiresAt) continue; + const remaining = Math.max(0, Math.floor((timer.picksExpiresAt.getTime() - now) / 1000)); + await db + .update(schema.draftTimers) + .set({ timeRemaining: remaining, picksExpiresAt: null, picksStartedAt: null, updatedAt: new Date() }) + .where(eq(schema.draftTimers.id, timer.id)); + } +} + +/** + * Called by the rollback route. Cancels any pending timeout and resets all timer + * rows to the initial bank. Does NOT reschedule — the rollback leaves the draft + * paused so the commissioner can review before resuming. + */ +export async function onDraftRolledBack(seasonId: string, initialTime: number): Promise { + cancelPickTimeout(seasonId); + cancelOvernightResumeTimeout(seasonId); + draftSlotsCache.delete(seasonId); + + await db + .update(schema.draftTimers) + .set({ timeRemaining: initialTime, picksExpiresAt: null, picksStartedAt: null, updatedAt: new Date() }) + .where(eq(schema.draftTimers.seasonId, seasonId)); +}