Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m39s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m24s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped

- 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 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-06-05 22:42:19 -07:00
parent f36480c828
commit 46f8552f60
15 changed files with 588 additions and 358 deletions

View file

@ -82,6 +82,8 @@ export function useDraftSocketEvents({
isDraftComplete?: boolean; isDraftComplete?: boolean;
}; };
const handlePickMade = (data: PickMadePayload) => { const handlePickMade = (data: PickMadePayload) => {
// Stop any running countdown — timer-pick-started for the next team will restart it.
setPickTimerExpiresAt(null);
if (isRevalidatingRef.current) { if (isRevalidatingRef.current) {
pendingPicksDuringRevalidationRef.current.push(data.pick); pendingPicksDuringRevalidationRef.current.push(data.pick);
} else { } else {
@ -117,12 +119,17 @@ export function useDraftSocketEvents({
expiresAt: number; expiresAt: number;
timeRemaining: number; timeRemaining: number;
}) => { }) => {
setCurrentPick(data.pickNumber);
setTeamTimers((prev) => ({ ...prev, [data.teamId]: data.timeRemaining })); setTeamTimers((prev) => ({ ...prev, [data.teamId]: data.timeRemaining }));
setPickTimerExpiresAt({ teamId: data.teamId, expiresAt: data.expiresAt }); setPickTimerExpiresAt({ teamId: data.teamId, expiresAt: data.expiresAt });
setIsOvernightPause(false); setIsOvernightPause(false);
setOvernightResumesAt(null); setOvernightResumesAt(null);
}; };
const handleTimerBankUpdated = (data: { teamId: string; timeRemaining: number }) => {
setTeamTimers((prev) => ({ ...prev, [data.teamId]: data.timeRemaining }));
};
const handleTimerOvernightPaused = (data: { const handleTimerOvernightPaused = (data: {
seasonId: string; seasonId: string;
teamId: string; teamId: string;
@ -133,7 +140,10 @@ export function useDraftSocketEvents({
setOvernightResumesAt(data.resumesAtUTC ? new Date(data.resumesAtUTC) : null); setOvernightResumesAt(data.resumesAtUTC ? new Date(data.resumesAtUTC) : null);
}; };
const handleDraftPaused = () => setIsPaused(true); const handleDraftPaused = () => {
setIsPaused(true);
setPickTimerExpiresAt(null);
};
const handleDraftResumed = () => setIsPaused(false); const handleDraftResumed = () => setIsPaused(false);
const handleDraftCompleted = () => setIsDraftComplete(true); const handleDraftCompleted = () => setIsDraftComplete(true);
@ -208,7 +218,8 @@ export function useDraftSocketEvents({
setPicks((prev) => prev.filter((p) => p.pickNumber < data.pickNumber)); setPicks((prev) => prev.filter((p) => p.pickNumber < data.pickNumber));
setCurrentPick(data.pickNumber); setCurrentPick(data.pickNumber);
setIsDraftComplete(false); setIsDraftComplete(false);
setIsPaused(false); setIsPaused(true);
setPickTimerExpiresAt(null);
}; };
const handleDraftStateSync = (data: { const handleDraftStateSync = (data: {
@ -216,6 +227,8 @@ export function useDraftSocketEvents({
currentPickNumber: number; currentPickNumber: number;
isPaused: boolean; isPaused: boolean;
status: string; status: string;
isOvernightPause?: boolean;
overnightResumesAt?: number;
timers?: Array<{ teamId: string; timeRemaining: number; expiresAt?: number }>; timers?: Array<{ teamId: string; timeRemaining: number; expiresAt?: number }>;
queue?: QueueItem[]; queue?: QueueItem[];
watchlistParticipantIds?: string[]; watchlistParticipantIds?: string[];
@ -231,12 +244,25 @@ export function useDraftSocketEvents({
data.timers?.forEach((t) => { updated[t.teamId] = t.timeRemaining; }); data.timers?.forEach((t) => { updated[t.teamId] = t.timeRemaining; });
return updated; return updated;
}); });
// Restore client-side countdown for the active team on reconnect. // Restore countdown for the active team. Filter out already-expired timestamps
const activeTimer = data.timers.find((t) => t.expiresAt !== undefined); // 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) { if (activeTimer?.expiresAt) {
setPickTimerExpiresAt({ teamId: activeTimer.teamId, expiresAt: 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) { if (data.queue) {
const q = data.queue; const q = data.queue;
setQueue(() => q); setQueue(() => q);
@ -269,6 +295,7 @@ export function useDraftSocketEvents({
}; };
on("pick-made", handlePickMade as (data: unknown) => void); 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-pick-started", handleTimerPickStarted as (data: unknown) => void);
on("timer-overnight-paused", handleTimerOvernightPaused as (data: unknown) => void); on("timer-overnight-paused", handleTimerOvernightPaused as (data: unknown) => void);
on("draft-paused", handleDraftPaused as (data: unknown) => void); on("draft-paused", handleDraftPaused as (data: unknown) => void);
@ -290,6 +317,7 @@ export function useDraftSocketEvents({
return () => { return () => {
off("pick-made", handlePickMade as (data: unknown) => void); 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-pick-started", handleTimerPickStarted as (data: unknown) => void);
off("timer-overnight-paused", handleTimerOvernightPaused as (data: unknown) => void); off("timer-overnight-paused", handleTimerOvernightPaused as (data: unknown) => void);
off("draft-paused", handleDraftPaused as (data: unknown) => void); off("draft-paused", handleDraftPaused as (data: unknown) => void);

View file

@ -187,12 +187,12 @@ describe("executeAutoPick — timer mode behavior", () => {
// ── chess_clock mode ──────────────────────────────────────────────────────── // ── chess_clock mode ────────────────────────────────────────────────────────
describe("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 }); const mockDb = makeMockDb({ draftTimerMode: "chess_clock", draftIncrementTime: 15 });
// Timer expired at 0; 0 + 15 = 15 after the increment
mockDb.returning mockDb.returning
.mockResolvedValueOnce([mockDraftPick]) // insert draft pick .mockResolvedValueOnce([mockDraftPick])
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 15 }]); // update timer .mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 15 }]);
await executeAutoPick({ await executeAutoPick({
seasonId: SEASON_ID, seasonId: SEASON_ID,
@ -203,9 +203,9 @@ describe("executeAutoPick — timer mode behavior", () => {
db: mockDb, db: mockDb,
}); });
expect(mockSocketIO.emit).toHaveBeenCalledWith( // 0 remaining + 15 increment = 15; picksExpiresAt cleared so next turn starts fresh
"timer-update", expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 15 }) expect.objectContaining({ timeRemaining: 15, picksExpiresAt: null, picksStartedAt: null })
); );
}); });
@ -245,9 +245,9 @@ describe("executeAutoPick — timer mode behavior", () => {
db: mockDb, db: mockDb,
}); });
expect(mockSocketIO.emit).toHaveBeenCalledWith( // 0 remaining (expired) + 30 increment = 30
"timer-update", expect(mockDb.set).toHaveBeenCalledWith(
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 }) expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
); );
}); });
@ -273,7 +273,7 @@ describe("executeAutoPick — timer mode behavior", () => {
// ── standard mode ─────────────────────────────────────────────────────────── // ── standard mode ───────────────────────────────────────────────────────────
describe("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 }); const mockDb = makeMockDb({ draftTimerMode: "standard", draftIncrementTime: 30 });
mockDb.returning mockDb.returning
.mockResolvedValueOnce([mockDraftPick]) .mockResolvedValueOnce([mockDraftPick])
@ -288,9 +288,8 @@ describe("executeAutoPick — timer mode behavior", () => {
db: mockDb, db: mockDb,
}); });
expect(mockSocketIO.emit).toHaveBeenCalledWith( expect(mockDb.set).toHaveBeenCalledWith(
"timer-update", expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
); );
}); });
@ -327,9 +326,8 @@ describe("executeAutoPick — timer mode behavior", () => {
db: mockDb, db: mockDb,
}); });
expect(mockSocketIO.emit).toHaveBeenCalledWith( expect(mockDb.set).toHaveBeenCalledWith(
"timer-update", expect.objectContaining({ timeRemaining: 90, picksExpiresAt: null, picksStartedAt: null })
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 90 })
); );
}); });

View file

@ -1,6 +1,6 @@
import { database } from "~/database/context"; import { database } from "~/database/context";
import * as schema from "~/database/schema"; 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 { logger } from "~/lib/logger";
import type { InferSelectModel } from "drizzle-orm"; import type { InferSelectModel } from "drizzle-orm";
import { getTeamQueue, getAllQueuesForSeason } from "./draft-queue"; import { getTeamQueue, getAllQueuesForSeason } from "./draft-queue";
@ -471,6 +471,7 @@ export async function executeAutoPick(params: {
autodraftSettings?: AutodraftSettings | null; autodraftSettings?: AutodraftSettings | null;
db?: ReturnType<typeof database>; db?: ReturnType<typeof database>;
chainEnabled?: boolean; // Set to false when called from within the autodraft chain to prevent recursion 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<{ }): Promise<{
success: boolean; success: boolean;
error?: string; error?: string;
@ -491,9 +492,13 @@ export async function executeAutoPick(params: {
commissionerUserId, commissionerUserId,
autodraftSettings, autodraftSettings,
db: providedDb, db: providedDb,
pickMadeAt: callerPickMadeAt,
} = params; } = params;
const db = providedDb || database(); 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 { try {
// Race condition protection - check if pick already made // 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 // Get draft slots to calculate round/pickInRound and get all team IDs
const draftSlots = await db.query.draftSlots.findMany({ const draftSlots = await db.query.draftSlots.findMany({
where: eq(schema.draftSlots.seasonId, seasonId), where: eq(schema.draftSlots.seasonId, seasonId),
@ -611,7 +620,8 @@ export async function executeAutoPick(params: {
? (commissionerUserId || "") ? (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 incrementTime = season.draftIncrementTime || 30;
const currentTimer = await db.query.draftTimers.findFirst({ const currentTimer = await db.query.draftTimers.findFirst({
where: and( where: and(
@ -624,6 +634,17 @@ export async function executeAutoPick(params: {
logger.warn(`[AutoPick] No timer found for team ${teamId} in season ${seasonId}`); 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 // 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 // ticks racing to the same pick slot are handled atomically at the DB level
// rather than relying on the TOCTOU pre-check above. // rather than relying on the TOCTOU pre-check above.
@ -638,8 +659,8 @@ export async function executeAutoPick(params: {
pickInRound, pickInRound,
pickedByUserId, pickedByUserId,
pickedByType: "auto", pickedByType: "auto",
// Records the team's bank balance at the moment the pick was made (seconds remaining) // Records the team's actual remaining time at the moment the pick was made
timeUsed: currentTimer ? currentTimer.timeRemaining : undefined, timeUsed: currentTimer ? timeRemainingAtPick : undefined,
}) })
.onConflictDoNothing() .onConflictDoNothing()
.returning(); .returning();
@ -660,63 +681,32 @@ export async function executeAutoPick(params: {
const isDraftComplete = nextPickNumber > totalPicks; const isDraftComplete = nextPickNumber > totalPicks;
// Update the team's timer after the auto-pick. // Update the team's timer after the auto-pick.
// Standard mode: reset to the per-pick time (atomic, prevents race with timer loop). // Standard mode: reset to the per-pick time.
// Chess clock mode: add the increment so the team starts their next turn with some time // Chess clock: credit actual remaining time + increment. When the timer fired, timeRemainingAtPick
// (without this, a single timeout would permanently freeze their bank at 0). // is 0, so the team gets exactly incrementTime — their bank is not accidentally refilled.
let emitTimeRemaining: number; // 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 const [updatedTimer] = await db
.update(schema.draftTimers) .update(schema.draftTimers)
.set({ .set({
timeRemaining: sql`${schema.draftTimers.timeRemaining} + ${incrementTime}`, timeRemaining: newTimeRemaining,
picksExpiresAt: null as Date | null,
picksStartedAt: null as Date | null,
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where( .where(and(eq(schema.draftTimers.seasonId, seasonId), eq(schema.draftTimers.teamId, teamId)))
and(
eq(schema.draftTimers.seasonId, seasonId),
eq(schema.draftTimers.teamId, teamId)
)
)
.returning(); .returning();
emitTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
if (!updatedTimer) { if (!updatedTimer) {
await db.insert(schema.draftTimers).values({ seasonId, teamId, timeRemaining: emitTimeRemaining }); await db.insert(schema.draftTimers).values({ seasonId, teamId, timeRemaining: newTimeRemaining });
} }
logger.log( logger.log(
`[AutoPick] Chess clock auto-pick for team ${teamId}, bank is now ${emitTimeRemaining}s (+${incrementTime}s increment)` `[AutoPick] Timer for team ${teamId}: ${newTimeRemaining}s (${season.draftTimerMode} mode, +${incrementTime}s increment)`
); );
}
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
// Update season's current pick number // Update season's current pick number
await db await db
@ -808,6 +798,10 @@ export async function executeAutoPick(params: {
isDraftComplete, 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 // Emit draft-completed event if applicable
if (isDraftComplete) { if (isDraftComplete) {
io.to(`draft-${seasonId}`).emit("draft-completed"); io.to(`draft-${seasonId}`).emit("draft-completed");
@ -817,16 +811,15 @@ export async function executeAutoPick(params: {
logger.error("[AutoPick] Socket.IO events error:", error); logger.error("[AutoPick] Socket.IO events error:", error);
} }
// Recompute Brackt EV/VORP after this pick is committed so autopick paths // Recompute Brackt EV/VORP after this pick is committed. Fire-and-forget so it
// (force-autopick, timer, user autodraft) are not one pick behind. // doesn't delay schedulePickForSeason (and therefore timer-pick-started) for the next team.
try { runBracktHarvilleForFantasySeason(seasonId, db)
const updates = await runBracktHarvilleForFantasySeason(seasonId, db); .then((updates) => {
if (updates.length > 0) { if (updates.length > 0) {
getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates }); getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates });
} }
} catch (error) { })
logger.error("[AutoPick] Error updating Brackt EVs after pick:", error); .catch((error) => logger.error("[AutoPick] Error updating Brackt EVs after pick:", error));
}
// Announce before triggering the chain so Discord messages arrive in pick-number // 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. // order. If the chain ran first, chained picks would announce before this one.

View file

@ -5,6 +5,7 @@ import * as schema from "~/database/schema";
import { isCommissioner } from "~/models/commissioner"; import { isCommissioner } from "~/models/commissioner";
import { logCommissionerAction } from "~/models/audit-log"; import { logCommissionerAction } from "~/models/audit-log";
import { getSocketIO } from "../../../server/socket"; import { getSocketIO } from "../../../server/socket";
import { rescheduleTimer } from "../../../server/timer";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
@ -59,6 +60,7 @@ export async function action(args: ActionFunctionArgs) {
); );
let newTime: number; let newTime: number;
let isOnClock = false;
if (!currentTimer) { if (!currentTimer) {
if (adjustment <= 0) { if (adjustment <= 0) {
@ -71,12 +73,32 @@ export async function action(args: ActionFunctionArgs) {
timeRemaining: newTime, timeRemaining: newTime,
}); });
} else { } else {
newTime = Math.max(0, currentTimer.timeRemaining + adjustment); 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 await db
.update(schema.draftTimers) .update(schema.draftTimers)
.set({ timeRemaining: newTime, updatedAt: new Date() }) .set({ timeRemaining: newTime, updatedAt: new Date() })
.where(eq(schema.draftTimers.id, currentTimer.id)); .where(eq(schema.draftTimers.id, currentTimer.id));
} }
}
const team = await db.query.teams.findFirst({ const team = await db.query.teams.findFirst({
where: eq(schema.teams.id, teamId), where: eq(schema.teams.id, teamId),
@ -96,18 +118,22 @@ export async function action(args: ActionFunctionArgs) {
}, },
}); });
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 { try {
getSocketIO() await rescheduleTimer(seasonId);
.to(`draft-${seasonId}`) } catch (err) {
.emit("timer-update", { logger.error("[AdjustTimeBank] rescheduleTimer failed:", err);
seasonId, }
teamId, } else {
timeRemaining: newTime, // Off-clock team: just push the updated bank to all clients.
currentPickNumber: season.currentPickNumber ?? 1, try {
}); getSocketIO().to(`draft-${seasonId}`).emit("timer-bank-updated", { teamId, timeRemaining: newTime });
} catch (error) { } catch (error) {
logger.error("Socket.IO error:", error); logger.error("Socket.IO error:", error);
} }
}
return Response.json({ success: true, timeRemaining: newTime }); return Response.json({ success: true, timeRemaining: newTime });
} }

View file

@ -5,9 +5,11 @@ import { eq } from "drizzle-orm";
import { executeAutoPick } from "~/models/draft-utils"; import { executeAutoPick } from "~/models/draft-utils";
import { isCommissioner } from "~/models/commissioner"; import { isCommissioner } from "~/models/commissioner";
import { logCommissionerAction } from "~/models/audit-log"; import { logCommissionerAction } from "~/models/audit-log";
import { rescheduleTimer } from "../../../server/timer";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
const pickMadeAt = Date.now(); // capture before any async work for accurate timer credit
const { request } = args; const { request } = args;
const session = await auth.api.getSession({ headers: args.request.headers }); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null; const userId = session?.user.id ?? null;
@ -49,12 +51,19 @@ export async function action(args: ActionFunctionArgs) {
triggeredBy: "commissioner", triggeredBy: "commissioner",
commissionerUserId: userId, commissionerUserId: userId,
db, db,
pickMadeAt,
}); });
if (!result.success) { if (!result.success) {
return Response.json({ error: result.error }, { status: 400 }); 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({ const team = await db.query.teams.findFirst({
where: eq(schema.teams.id, teamId), where: eq(schema.teams.id, teamId),
}); });

View file

@ -19,6 +19,7 @@ import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
const pickMadeAt = Date.now(); // capture before any async work for accurate timer credit
const { request } = args; const { request } = args;
const session = await auth.api.getSession({ headers: args.request.headers }); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null; const userId = session?.user.id ?? null;
@ -180,10 +181,11 @@ export async function action(args: ActionFunctionArgs) {
const timerSnapshot = await db.query.draftTimers.findFirst({ const timerSnapshot = await db.query.draftTimers.findFirst({
where: and(eq(schema.draftTimers.seasonId, seasonId), eq(schema.draftTimers.teamId, teamId)), where: and(eq(schema.draftTimers.seasonId, seasonId), eq(schema.draftTimers.teamId, teamId)),
}); });
const pickMadeAt = Date.now(); const timeRemainingAtPick = (() => {
const timeRemainingAtPick = timerSnapshot?.picksExpiresAt if (!timerSnapshot?.picksExpiresAt) return timerSnapshot?.timeRemaining ?? 0;
? Math.max(0, Math.floor((timerSnapshot.picksExpiresAt.getTime() - pickMadeAt) / 1000)) const msRemaining = timerSnapshot.picksExpiresAt.getTime() - pickMadeAt;
: (timerSnapshot?.timeRemaining ?? 0); return msRemaining > 0 ? Math.ceil(msRemaining / 1000) : 0;
})();
const newTimeRemaining = const newTimeRemaining =
season.draftTimerMode === "standard" ? incrementTime : timeRemainingAtPick + incrementTime; season.draftTimerMode === "standard" ? incrementTime : timeRemainingAtPick + incrementTime;
@ -246,6 +248,9 @@ export async function action(args: ActionFunctionArgs) {
isDraftComplete, 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) { if (isDraftComplete) {
io.to(`draft-${seasonId}`).emit("draft-completed"); io.to(`draft-${seasonId}`).emit("draft-completed");
scheduleDraftRoomClosure(seasonId); scheduleDraftRoomClosure(seasonId);
@ -290,14 +295,13 @@ export async function action(args: ActionFunctionArgs) {
// Check if next team has autodraft enabled and trigger immediately // Check if next team has autodraft enabled and trigger immediately
if (!isDraftComplete) { if (!isDraftComplete) {
try { runBracktHarvilleForFantasySeason(seasonId, db)
const updates = await runBracktHarvilleForFantasySeason(seasonId, db); .then((updates) => {
if (updates.length > 0) { if (updates.length > 0) {
getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates }); getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates });
} }
} catch (error) { })
logger.error("Brackt EV update after forced manual pick failed:", error); .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) }); const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) });
if (!freshSeason?.draftPaused) { if (!freshSeason?.draftPaused) {

View file

@ -19,6 +19,7 @@ import { enqueuePickNotification } from "~/services/discord";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
const pickMadeAt = Date.now(); // capture before any async work for accurate timer credit
const { request } = args; const { request } = args;
const session = await auth.api.getSession({ headers: args.request.headers }); const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null; const userId = session?.user.id ?? null;
@ -150,10 +151,11 @@ export async function action(args: ActionFunctionArgs) {
eq(schema.draftTimers.teamId, currentDraftSlot.teamId) eq(schema.draftTimers.teamId, currentDraftSlot.teamId)
), ),
}); });
const pickMadeAt = Date.now(); const timeRemainingAtPick = (() => {
const timeRemainingAtPick = timerSnapshot?.picksExpiresAt if (!timerSnapshot?.picksExpiresAt) return timerSnapshot?.timeRemaining ?? 0;
? Math.max(0, Math.floor((timerSnapshot.picksExpiresAt.getTime() - pickMadeAt) / 1000)) const msRemaining = timerSnapshot.picksExpiresAt.getTime() - pickMadeAt;
: (timerSnapshot?.timeRemaining ?? 0); return msRemaining > 0 ? Math.ceil(msRemaining / 1000) : 0;
})();
// Create the draft pick // Create the draft pick
const [draftPick] = await db 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) // Update season's current pick number (AFTER initializing next timer to prevent race condition)
await db await db
.update(schema.seasons) .update(schema.seasons)
@ -280,6 +279,13 @@ export async function action(args: ActionFunctionArgs) {
isDraftComplete, 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) { if (isDraftComplete) {
getSocketIO().to(`draft-${seasonId}`).emit("draft-completed"); getSocketIO().to(`draft-${seasonId}`).emit("draft-completed");
scheduleDraftRoomClosure(seasonId); scheduleDraftRoomClosure(seasonId);
@ -310,14 +316,14 @@ export async function action(args: ActionFunctionArgs) {
// Check if next team has autodraft enabled and trigger immediately // Check if next team has autodraft enabled and trigger immediately
if (!isDraftComplete) { if (!isDraftComplete) {
try { // Fire-and-forget so it doesn't delay rescheduleTimer (and timer-pick-started) for the next team.
const updates = await runBracktHarvilleForFantasySeason(seasonId, db); runBracktHarvilleForFantasySeason(seasonId, db)
.then((updates) => {
if (updates.length > 0) { if (updates.length > 0) {
getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates }); getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates });
} }
} catch (error) { })
logger.error("Brackt EV update after pick failed:", error); .catch((error) => logger.error("Brackt EV update after pick failed:", error));
}
const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) }); const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) });
if (!freshSeason?.draftPaused) { if (!freshSeason?.draftPaused) {

View file

@ -5,6 +5,7 @@ import * as schema from "~/database/schema";
import { isCommissioner } from "~/models/commissioner"; import { isCommissioner } from "~/models/commissioner";
import { logCommissionerAction } from "~/models/audit-log"; import { logCommissionerAction } from "~/models/audit-log";
import { getSocketIO } from "../../../server/socket"; import { getSocketIO } from "../../../server/socket";
import { onDraftPaused } from "../../../server/timer";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
@ -65,6 +66,13 @@ export async function action(args: ActionFunctionArgs) {
details: { pickNumber: season.currentPickNumber }, 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 // Emit socket event
try { try {
getSocketIO().to(`draft-${seasonId}`).emit("draft-paused", { getSocketIO().to(`draft-${seasonId}`).emit("draft-paused", {

View file

@ -5,6 +5,7 @@ import * as schema from "~/database/schema";
import { isCommissioner } from "~/models/commissioner"; import { isCommissioner } from "~/models/commissioner";
import { logCommissionerAction } from "~/models/audit-log"; import { logCommissionerAction } from "~/models/audit-log";
import { getSocketIO } from "../../../server/socket"; import { getSocketIO } from "../../../server/socket";
import { rescheduleTimer } from "../../../server/timer";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
@ -65,6 +66,13 @@ export async function action(args: ActionFunctionArgs) {
details: { pickNumber: season.currentPickNumber }, 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 // Emit socket event
try { try {
getSocketIO().to(`draft-${seasonId}`).emit("draft-resumed", { getSocketIO().to(`draft-${seasonId}`).emit("draft-resumed", {

View file

@ -5,6 +5,7 @@ import { eq, and, gte } from "drizzle-orm";
import { isCommissioner } from "~/models/commissioner"; import { isCommissioner } from "~/models/commissioner";
import { logCommissionerAction } from "~/models/audit-log"; import { logCommissionerAction } from "~/models/audit-log";
import { getSocketIO } from "../../../server/socket"; import { getSocketIO } from "../../../server/socket";
import { onDraftRolledBack } from "../../../server/timer";
import { logger } from "~/lib/logger"; import { logger } from "~/lib/logger";
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server"; 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 await db
.update(schema.seasons) .update(schema.seasons)
.set({ .set({
currentPickNumber: pickNumber, currentPickNumber: pickNumber,
draftPaused: false, draftPaused: true,
}) })
.where(eq(schema.seasons.id, seasonId)); .where(eq(schema.seasons.id, seasonId));
@ -99,7 +101,10 @@ export async function action(args: ActionFunctionArgs) {
}); });
try { 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, seasonId,
pickNumber, pickNumber,
teamId: rollbackSlot?.teamId, teamId: rollbackSlot?.teamId,
@ -117,5 +122,18 @@ export async function action(args: ActionFunctionArgs) {
logger.error("[Rollback] Brackt EV update failed:", error); 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 }); return Response.json({ success: true, pickNumber });
} }

View file

@ -6,6 +6,7 @@ import { isCommissioner } from "~/models/commissioner";
import { findUserById, getUserDisplayName } from "~/models/user"; import { findUserById, getUserDisplayName } from "~/models/user";
import { getSocketIO } from "../../../server/socket"; import { getSocketIO } from "../../../server/socket";
import { startDraft } from "~/services/draft-autostart"; import { startDraft } from "~/services/draft-autostart";
import { rescheduleTimer } from "../../../server/timer";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
@ -58,5 +59,13 @@ export async function action(args: ActionFunctionArgs) {
return Response.json({ error: result.error }, { status }); 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 }); return Response.json({ success: true });
} }

View file

@ -304,13 +304,20 @@ export default function DraftRoom() {
}, [season.status, season.autoStartDraft, season.draftDateTime, nowForPauseCheck]); }, [season.status, season.autoStartDraft, season.draftDateTime, nowForPauseCheck]);
const draftBoardUrl = `/leagues/${season.leagueId}/draft-board/${season.id}`; 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<number | null>(null);
if (isDraftComplete && draftCompletedAtClientRef.current === null) {
draftCompletedAtClientRef.current = season.draftCompletedAt?.getTime() ?? Date.now();
}
const roomClosureCountdown = useMemo(() => { const roomClosureCountdown = useMemo(() => {
if (!isDraftComplete) return null; if (!isDraftComplete) return null;
const completedAt = season.draftCompletedAt; const baseMs = season.draftCompletedAt?.getTime() ?? draftCompletedAtClientRef.current ?? Date.now();
if (!completedAt) return 300; const elapsed = (nowForPauseCheck.getTime() - baseMs) / 1000;
const elapsed = (nowForPauseCheck.getTime() - completedAt.getTime()) / 1000; return Math.max(0, Math.ceil(300 - elapsed));
const remaining = Math.max(0, 300 - elapsed);
return Math.ceil(remaining);
}, [isDraftComplete, season.draftCompletedAt, nowForPauseCheck]); }, [isDraftComplete, season.draftCompletedAt, nowForPauseCheck]);
useEffect(() => { useEffect(() => {
@ -497,18 +504,18 @@ export default function DraftRoom() {
const [pickTimerExpiresAt, setPickTimerExpiresAt] = useState<{ teamId: string; expiresAt: number } | null>(() => { const [pickTimerExpiresAt, setPickTimerExpiresAt] = useState<{ teamId: string; expiresAt: number } | null>(() => {
// Seed from initial loader data if a timer is already running. // Seed from initial loader data if a timer is already running.
const active = timers.find((t) => t.picksExpiresAt && t.picksExpiresAt.getTime() > Date.now()); 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(() => { useEffect(() => {
if (!pickTimerExpiresAt) return; if (!pickTimerExpiresAt) return;
const { teamId, expiresAt } = pickTimerExpiresAt; const { teamId, expiresAt } = pickTimerExpiresAt;
const tick = () => { 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 }); setTeamTimers((prev) => prev[teamId] === remaining ? prev : { ...prev, [teamId]: remaining });
}; };
tick(); // immediate update so display is accurate on mount tick(); // immediate update so display is accurate on mount
const id = setInterval(tick, 1_000); const id = setInterval(tick, 500);
return () => clearInterval(id); return () => clearInterval(id);
}, [pickTimerExpiresAt, setTeamTimers]); }, [pickTimerExpiresAt, setTeamTimers]);
@ -550,6 +557,13 @@ export default function DraftRoom() {
} }
}, [sidebarCollapsed]); }, [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 // Queue handlers
const handleAddToQueue = useCallback(async (participantId: string) => { const handleAddToQueue = useCallback(async (participantId: string) => {
if (!userTeam) return; if (!userTeam) return;
@ -1039,7 +1053,7 @@ export default function DraftRoom() {
const isMyTurn = !!( const isMyTurn = !!(
userTeam && currentDraftSlot && currentDraftSlot.team.id === userTeam.id 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 currentDraftSlotOwnerName = currentDraftSlot ? ownerMap[currentDraftSlot.team.id] : undefined;
const currentClockTime = currentDraftSlot ? teamTimers[currentDraftSlot.team.id] : undefined; const currentClockTime = currentDraftSlot ? teamTimers[currentDraftSlot.team.id] : undefined;

View file

@ -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<string, Map<string, string | null>>();
async function getTeamTimezone(seasonId: string, teamId: string): Promise<string | null> {
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<typeof schema.seasons>,
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<string> {
return teamTimezoneCache.keys();
}
export function clearAllOvernightPauseCaches(): void {
teamTimezoneCache.clear();
}

View file

@ -3,6 +3,8 @@ import { Server as SocketIOServer } from "socket.io";
import type { Server as HTTPServer } from "http"; import type { Server as HTTPServer } from "http";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and, asc } from "drizzle-orm"; import { eq, and, asc } from "drizzle-orm";
import { checkOvernightPause } from "./overnight-pause-check";
import { calculatePickInfo } from "~/models/draft-utils";
import { logger } from "./logger"; import { logger } from "./logger";
import { db } from "./db"; import { db } from "./db";
@ -18,6 +20,7 @@ interface ServerToClientEvents {
"draft-started": (data: { seasonId: string; currentPickNumber: number }) => void; "draft-started": (data: { seasonId: string; currentPickNumber: number }) => void;
"draft-completed": () => void; "draft-completed": () => void;
"draft-room-closed": () => void; "draft-room-closed": () => void;
"timer-bank-updated": (data: { teamId: string; timeRemaining: number }) => void;
"timer-pick-started": (data: { "timer-pick-started": (data: {
seasonId: string; seasonId: string;
teamId: string; teamId: string;
@ -53,6 +56,8 @@ interface ServerToClientEvents {
currentPickNumber: number; currentPickNumber: number;
isPaused: boolean; isPaused: boolean;
status: string; status: string;
isOvernightPause: boolean;
overnightResumesAt?: number;
picks: Array<{ picks: Array<{
id: string; id: string;
pickNumber: number; pickNumber: number;
@ -231,7 +236,7 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
// tokens or flaky mobile networks. This socket-based sync provides an // tokens or flaky mobile networks. This socket-based sync provides an
// additional, more reliable path since the socket is already connected. // additional, more reliable path since the socket is already connected.
try { try {
const [seasonData, picks, timerRows, queueItems, watchlistItems] = await Promise.all([ const [seasonData, picks, timerRows, draftSlots, queueItems, watchlistItems] = await Promise.all([
db.query.seasons.findFirst({ db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId), where: eq(schema.seasons.id, seasonId),
}), }),
@ -265,6 +270,10 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
db.query.draftTimers.findMany({ db.query.draftTimers.findMany({
where: eq(schema.draftTimers.seasonId, seasonId), where: eq(schema.draftTimers.seasonId, seasonId),
}), }),
db.query.draftSlots.findMany({
where: eq(schema.draftSlots.seasonId, seasonId),
orderBy: asc(schema.draftSlots.draftOrder),
}),
teamId teamId
? db.query.draftQueue.findMany({ ? db.query.draftQueue.findMany({
where: and( where: and(
@ -285,10 +294,33 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
]); ]);
if (seasonData) { 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", { socket.emit("draft-state-sync", {
currentPickNumber: seasonData.currentPickNumber || 1, currentPickNumber: seasonData.currentPickNumber || 1,
isPaused: seasonData.draftPaused || false, isPaused: seasonData.draftPaused || false,
status: seasonData.status, status: seasonData.status,
isOvernightPause,
overnightResumesAt,
picks, picks,
timers: timerRows.map((t) => { timers: timerRows.map((t) => {
const nowMs = Date.now(); const nowMs = Date.now();

View file

@ -3,7 +3,7 @@ import { eq, and, asc, lte, isNotNull } from "drizzle-orm";
import type { InferSelectModel } from "drizzle-orm"; import type { InferSelectModel } from "drizzle-orm";
import { getSocketIO } from "./socket"; import { getSocketIO } from "./socket";
import { executeAutoPick, calculatePickInfo } from "~/models/draft-utils"; 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 { logger } from "./logger";
import { db } from "./db"; import { db } from "./db";
import { startDraft } from "~/services/draft-autostart"; import { startDraft } from "~/services/draft-autostart";
@ -16,57 +16,8 @@ const schedulingInProgress = new Set<string>(); // prevents conc
// Draft slots never change during an active draft — cache them to avoid a redundant query. // Draft slots never change during an active draft — cache them to avoid a redundant query.
const draftSlotsCache = new Map<string, { teamId: string; draftOrder: number }[]>(); const draftSlotsCache = new Map<string, { teamId: string; draftOrder: number }[]>();
// Team timezone cache for per_user overnight pause mode (seasonId → teamId → timezone).
const teamTimezoneCache = new Map<string, Map<string, string | null>>();
let recoveryInterval: NodeJS.Timeout | null = null; let recoveryInterval: NodeJS.Timeout | null = null;
async function getTeamTimezone(seasonId: string, teamId: string): Promise<string | null> {
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<typeof schema.seasons>,
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 }[]> { async function getDraftSlotsCached(seasonId: string): Promise<{ teamId: string; draftOrder: number }[]> {
let slots = draftSlotsCache.get(seasonId); let slots = draftSlotsCache.get(seasonId);
if (!slots) { if (!slots) {
@ -170,7 +121,12 @@ async function triggerAutoPick(
} }
// Core scheduling logic — called after acquiring the schedulingInProgress lock. // 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<void> { async function _schedulePickForSeason(seasonId: string): Promise<void> {
const MAX_CONSECUTIVE_AUTOPICKS = 100; // safety cap against infinite loops on data bugs
for (let iteration = 0; iteration < MAX_CONSECUTIVE_AUTOPICKS; iteration++) {
const season = await db.query.seasons.findFirst({ const season = await db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId), where: eq(schema.seasons.id, seasonId),
}); });
@ -206,16 +162,11 @@ async function _schedulePickForSeason(seasonId: string): Promise<void> {
expiresAt: Date.now(), expiresAt: Date.now(),
timeRemaining: 0, timeRemaining: 0,
}); });
} catch (_) { /* non-fatal */ } } catch { /* non-fatal */ }
const success = await triggerAutoPick(seasonId, currentTeamId, currentPickNumber, autodraftSettings ?? null); const success = await triggerAutoPick(seasonId, currentTeamId, currentPickNumber, autodraftSettings ?? null);
if (!success) { if (!success) { await pauseDraftOnError(seasonId, currentTeamId); return; }
await pauseDraftOnError(seasonId, currentTeamId); continue; // re-read season state — currentPickNumber has advanced
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. // Overnight pause: freeze timer; schedule a wakeup for when the window ends.
@ -229,7 +180,7 @@ async function _schedulePickForSeason(seasonId: string): Promise<void> {
teamId: currentTeamId, teamId: currentTeamId,
resumesAtUTC: overnightPause.resumesAtUTC, resumesAtUTC: overnightPause.resumesAtUTC,
}); });
} catch (_) { /* non-fatal */ } } catch { /* non-fatal */ }
if (overnightPause.resumesAtUTC) { if (overnightPause.resumesAtUTC) {
const msUntilResume = Math.max(0, overnightPause.resumesAtUTC - Date.now()); const msUntilResume = Math.max(0, overnightPause.resumesAtUTC - Date.now());
@ -284,7 +235,7 @@ async function _schedulePickForSeason(seasonId: string): Promise<void> {
// Fresh start of this team's turn (normal path after a pick or on startup). // Fresh start of this team's turn (normal path after a pick or on startup).
const bank = timer.timeRemaining; const bank = timer.timeRemaining;
if (bank <= 0) { if (bank <= 0) {
// Bank depleted — trigger autopick immediately. // Bank depleted — trigger autopick immediately and loop to schedule the next team.
const success = await triggerAutoPick( const success = await triggerAutoPick(
seasonId, seasonId,
currentTeamId, currentTeamId,
@ -292,8 +243,7 @@ async function _schedulePickForSeason(seasonId: string): Promise<void> {
shouldAutodraft ? (autodraftSettings ?? null) : null shouldAutodraft ? (autodraftSettings ?? null) : null
); );
if (!success) { await pauseDraftOnError(seasonId, currentTeamId); return; } if (!success) { await pauseDraftOnError(seasonId, currentTeamId); return; }
await _schedulePickForSeason(seasonId); continue;
return;
} }
expiresAt = new Date(now + bank * 1000); expiresAt = new Date(now + bank * 1000);
await db await db
@ -319,7 +269,7 @@ async function _schedulePickForSeason(seasonId: string): Promise<void> {
expiresAt: expiresAt.getTime(), expiresAt: expiresAt.getTime(),
timeRemaining, timeRemaining,
}); });
} catch (_) { /* non-fatal */ } } catch { /* non-fatal */ }
if (msUntilExpiry <= 0) { if (msUntilExpiry <= 0) {
// Already expired (e.g. picked up on recovery interval after a long pause). // Already expired (e.g. picked up on recovery interval after a long pause).
@ -330,12 +280,21 @@ async function _schedulePickForSeason(seasonId: string): Promise<void> {
shouldAutodraft ? (autodraftSettings ?? null) : null shouldAutodraft ? (autodraftSettings ?? null) : null
); );
if (!success) { await pauseDraftOnError(seasonId, currentTeamId); return; } if (!success) { await pauseDraftOnError(seasonId, currentTeamId); return; }
await _schedulePickForSeason(seasonId); continue;
return;
} }
const timeout = setTimeout(async () => { const timeout = setTimeout(async () => {
pickTimeouts.delete(seasonId); 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( logger.log(
`[Timer] ⚠️ Timer expired for team ${currentTeamId} in season ${seasonId} (pick ${currentPickNumber})` `[Timer] ⚠️ Timer expired for team ${currentTeamId} in season ${seasonId} (pick ${currentPickNumber})`
); );
@ -349,10 +308,19 @@ async function _schedulePickForSeason(seasonId: string): Promise<void> {
await pauseDraftOnError(seasonId, currentTeamId); await pauseDraftOnError(seasonId, currentTeamId);
return; return;
} }
await schedulePickForSeason(seasonId); await _schedulePickForSeason(seasonId);
} catch (err) {
logger.error(`[Timer] Error in timer callback for ${seasonId}:`, err);
} finally {
schedulingInProgress.delete(seasonId);
}
}, msUntilExpiry); }, msUntilExpiry);
pickTimeouts.set(seasonId, timeout); pickTimeouts.set(seasonId, timeout);
return; // timer is set — done until it fires
}
logger.error(`[Timer] Hit max consecutive auto-picks (${MAX_CONSECUTIVE_AUTOPICKS}) for season ${seasonId}`);
} }
async function schedulePickForSeason(seasonId: string): Promise<void> { async function schedulePickForSeason(seasonId: string): Promise<void> {
@ -375,12 +343,13 @@ async function scheduleAllActiveDrafts(): Promise<void> {
}); });
// Evict caches for seasons no longer drafting. // 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)); const activeIds = new Set(activeDrafts.map((s) => s.id));
for (const id of draftSlotsCache.keys()) { const staleIds = new Set([...draftSlotsCache.keys(), ...overnightPauseCacheKeys()].filter((id) => !activeIds.has(id)));
if (!activeIds.has(id)) draftSlotsCache.delete(id); for (const id of staleIds) {
} draftSlotsCache.delete(id);
for (const id of teamTimezoneCache.keys()) { evictOvernightPauseCache(id);
if (!activeIds.has(id)) teamTimezoneCache.delete(id);
} }
for (const season of activeDrafts) { for (const season of activeDrafts) {
@ -424,7 +393,7 @@ export function stopDraftTimerSystem(): void {
overnightResumeTimeouts.clear(); overnightResumeTimeouts.clear();
schedulingInProgress.clear(); schedulingInProgress.clear();
draftSlotsCache.clear(); draftSlotsCache.clear();
teamTimezoneCache.clear(); clearAllOvernightPauseCaches();
logger.log("[Timer] Draft timer system stopped"); logger.log("[Timer] Draft timer system stopped");
} }
@ -435,6 +404,46 @@ export function stopDraftTimerSystem(): void {
export async function rescheduleTimer(seasonId: string): Promise<void> { export async function rescheduleTimer(seasonId: string): Promise<void> {
cancelPickTimeout(seasonId); cancelPickTimeout(seasonId);
cancelOvernightResumeTimeout(seasonId); cancelOvernightResumeTimeout(seasonId);
draftSlotsCache.delete(seasonId); // pick may have changed the draft order state
await schedulePickForSeason(seasonId); 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<void> {
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<void> {
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));
}