brackt/server/timer.ts

450 lines
16 KiB
TypeScript
Raw Normal View History

import * as schema from "~/database/schema";
import { eq, and, asc, lte, isNotNull } from "drizzle-orm";
fix: harden draft timer system with race-condition safety and DRY refactor (#34) - Fix settings action silently resetting timer values when draft speed select is disabled (add null guard before overwriting draftInitialTime/ draftIncrementTime) - Make timer decrement and pick increment atomic using SQL expressions to prevent read-modify-write races between the timer loop and HTTP handlers - Add UNIQUE INDEX on (season_id, pick_number) in draft_picks to prevent duplicate picks from concurrent requests (TOCTOU guard) - Add socket join-draft team ownership validation via DB query - Add iteration cap to autodraft chain while loop (max = totalTeams) - Add draftPaused re-check before firing autodraft chain in make-pick and force-manual-pick - Consolidate all snake draft calculations into calculatePickInfo (DRY); remove duplicated logic from timer.ts, make-pick, force-manual-pick, executeAutoPick, and checkAndTriggerNextAutodraft - Fix calculatePickInfo to return snake-adjusted pickInRound matching draftOrder values instead of raw pre-snake value - Fix timer-update socket events to emit nextPickNumber after a pick instead of the already-completed currentPickNumber - Fix force-manual-pick to validate submitted teamId matches the team whose turn it actually is at the given pick number - Replace inline timer init in draft.start with deleteSeasonTimers + initializeDraftTimers model functions (dead code fix) - Fix draft loader to not crash when getTeamQueue fails (returns []) - Fix misleading 403 message for commissioner picks - Remove dead hidden inputs from league settings form - Document connectedTeams single-instance limitation in socket.ts Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 23:23:24 -08:00
import type { InferSelectModel } from "drizzle-orm";
import { getSocketIO } from "./socket";
fix: harden draft timer system with race-condition safety and DRY refactor (#34) - Fix settings action silently resetting timer values when draft speed select is disabled (add null guard before overwriting draftInitialTime/ draftIncrementTime) - Make timer decrement and pick increment atomic using SQL expressions to prevent read-modify-write races between the timer loop and HTTP handlers - Add UNIQUE INDEX on (season_id, pick_number) in draft_picks to prevent duplicate picks from concurrent requests (TOCTOU guard) - Add socket join-draft team ownership validation via DB query - Add iteration cap to autodraft chain while loop (max = totalTeams) - Add draftPaused re-check before firing autodraft chain in make-pick and force-manual-pick - Consolidate all snake draft calculations into calculatePickInfo (DRY); remove duplicated logic from timer.ts, make-pick, force-manual-pick, executeAutoPick, and checkAndTriggerNextAutodraft - Fix calculatePickInfo to return snake-adjusted pickInRound matching draftOrder values instead of raw pre-snake value - Fix timer-update socket events to emit nextPickNumber after a pick instead of the already-completed currentPickNumber - Fix force-manual-pick to validate submitted teamId matches the team whose turn it actually is at the given pick number - Replace inline timer init in draft.start with deleteSeasonTimers + initializeDraftTimers model functions (dead code fix) - Fix draft loader to not crash when getTeamQueue fails (returns []) - Fix misleading 403 message for commissioner picks - Remove dead hidden inputs from league settings form - Document connectedTeams single-instance limitation in socket.ts Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 23:23:24 -08:00
import { executeAutoPick, calculatePickInfo } from "~/models/draft-utils";
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause - 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>
2026-06-05 22:42:19 -07:00
import { checkOvernightPause, evictOvernightPauseCache, overnightPauseCacheKeys, clearAllOvernightPauseCaches } from "./overnight-pause-check";
import { logger } from "./logger";
Add CS2 Major Qualifying Points simulator and stage management (#260) * Add CS2 Major qualifying points simulator Implements a full CS2 Major tournament simulator with: - 3-stage Swiss format (Opening Bo1, Elimination Bo1/Bo3, Decider all Bo3) + Champions Stage 8-team single-elimination (QF Bo3, SF Bo3, GF Bo5) - Monte Carlo simulation (10,000 iterations) accumulating QP across 2 majors/season - Sampled 24-team field per iteration: top 12 guaranteed, remaining weighted by 1/rank - Stage 3 exits (placements 9-16) sub-ranked by W-L record (2-3 > 1-3 > 0-3) - Stage assignments stored per-event so actual field composition drives simulation - Admin CS Elo form for entering team Elo + HLTV world rankings - Admin CS2 stage setup page for assigning teams to stages and tracking advancement - Database migration: cs2_major_qualifying_points enum value + cs2_major_stage_results table - 24 unit tests covering all exported pure functions https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR * Consolidate Elo + ranking input into generic elo-ratings page The darts-elo and cs-elo pages were unreachable from the admin nav, which always links to the generic elo-ratings page. Extended elo-ratings to conditionally show world ranking fields for simulator types that need it (darts_bracket, cs2_major_qualifying_points), then deleted the redundant sport-specific pages. https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR * Consolidate server postgres connections into one shared pool Four separate postgres() clients were open simultaneously (app, timer, snapshots, socket), each defaulting to 10 connections, exhausting the database's max_connections limit. Replaced with a single shared lazy- initialized client in server/db.ts using a Proxy to defer the DATABASE_URL check until first use (preserving test compatibility). Also bumps the CS2 Champions Stage stochastic test from 200 → 1000 iterations to eliminate flakiness. https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR * Fix and() bug and add Swiss loop safety guard - cs2-major-stage.ts: markCs2StageEliminations and setCs2FinalPlacements were using JS && instead of Drizzle and(), causing WHERE to filter only by participantId (not scoringEventId), which would update rows across all events instead of just the target event - cs-major-simulator.ts: add break guard in simulateSwiss while loop to prevent infinite loop if pairGroups returns no pairs https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR * Fix all remaining code review issues - cs2-major-stage.ts: use schema column reference for stageEliminated in markCs2StageEliminations instead of raw SQL string - cs-major-simulator.ts: simulateOneMajor now locks in known stage results when a stage is complete (8 recorded eliminations), only simulating the remaining stages during live events - admin event page: add CS2 Stage Setup button for cs2_major_qualifying_points simulator types; expose simulatorType in server loader type cast - cs2-setup.tsx: replace document.getElementById DOM manipulation with React state (eliminatedChecked map) for checkbox show/hide logic; remove unused stageMap and unassignedParticipants variables https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR * Fix oxlint errors: non-null assertions, sort→toSorted, unused vars - cs-major-simulator.ts: replace 5 non-null assertions (!) with safe optional chaining / if-guards; replace 6 .sort() with .toSorted() - cs2-major-stage.ts: remove unused `inArray` import - cs2-setup.tsx: remove unused `assignedIds` variable https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR * Fix flaky Champions Stage stochastic test The makeTeams(8) helper creates only a 70-pt Elo spread (1800→1730). With the Champions Stage bracket math this gives team-0 a ~19.6% win rate — right at the 0.2 threshold, causing the test to fail ~63% of the time in CI despite 1000 iterations. Use 100-pt steps (1800→1100) instead, giving team-0 a ~40% win rate and raising the assertion threshold to 0.25 for a clear safety margin. https://claude.ai/code/session_019w21Nkf5TvTZHH6oVHaQXR --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-04-05 13:40:05 -07:00
import { db } from "./db";
import { startDraft } from "~/services/draft-autostart";
// Per-season in-memory state
const pickTimeouts = new Map<string, NodeJS.Timeout>(); // seasonId → active setTimeout
const overnightResumeTimeouts = new Map<string, NodeJS.Timeout>(); // seasonId → resume setTimeout
const schedulingInProgress = new Set<string>(); // prevents concurrent scheduling
// Draft slots never change during an active draft — cache them to avoid a redundant query.
const draftSlotsCache = new Map<string, { teamId: string; draftOrder: number }[]>();
let recoveryInterval: NodeJS.Timeout | null = null;
async function getDraftSlotsCached(seasonId: string): Promise<{ teamId: string; draftOrder: number }[]> {
let slots = draftSlotsCache.get(seasonId);
if (!slots) {
slots = await db.query.draftSlots.findMany({
where: eq(schema.draftSlots.seasonId, seasonId),
orderBy: asc(schema.draftSlots.draftOrder),
});
draftSlotsCache.set(seasonId, slots);
}
return slots;
}
async function checkAndAutoStartDrafts(): Promise<void> {
const io = getSocketIO();
const seasonsToStart = await db.query.seasons.findMany({
where: and(
eq(schema.seasons.status, "pre_draft"),
eq(schema.seasons.autoStartDraft, true),
isNotNull(schema.seasons.draftDateTime),
lte(schema.seasons.draftDateTime, new Date())
),
});
for (const season of seasonsToStart) {
logger.log(`[Timer] Auto-starting draft for season ${season.id}`);
const result = await startDraft({
seasonId: season.id,
actorUserId: "system",
actorDisplayName: "System (auto-start)",
db,
io,
});
if (result.success) continue;
if (result.error === "Draft already started or completed") continue;
logger.error(`[Timer] Auto-start failed for season ${season.id}: ${result.error}`);
if (result.error === "No draft slots found for this season") {
await db
.update(schema.seasons)
.set({ autoStartDraft: false })
.where(eq(schema.seasons.id, season.id));
logger.log(`[Timer] Disabled autoStartDraft for season ${season.id} — no draft order set`);
}
}
}
function cancelPickTimeout(seasonId: string): void {
const existing = pickTimeouts.get(seasonId);
if (existing) {
clearTimeout(existing);
pickTimeouts.delete(seasonId);
}
}
function cancelOvernightResumeTimeout(seasonId: string): void {
const existing = overnightResumeTimeouts.get(seasonId);
if (existing) {
clearTimeout(existing);
overnightResumeTimeouts.delete(seasonId);
}
}
async function pauseDraftOnError(seasonId: string, teamId: string): Promise<void> {
logger.error(`[Timer] Pausing draft ${seasonId} — pick failed for team ${teamId}`);
await db.update(schema.seasons).set({ draftPaused: true }).where(eq(schema.seasons.id, seasonId));
try {
getSocketIO().to(`draft-${seasonId}`).emit("draft-paused", { seasonId, paused: true });
} catch (err) {
logger.error("[Timer] Failed to emit draft-paused:", err);
}
}
async function triggerAutoPick(
seasonId: string,
teamId: string,
pickNumber: number,
autodraftSettings: InferSelectModel<typeof schema.autodraftSettings> | null
): Promise<boolean> {
try {
const result = await executeAutoPick({
seasonId,
teamId,
pickNumber,
triggeredBy: "timer",
autodraftSettings,
db,
});
if (!result.success) {
if (result.error === "Pick already made") return true;
logger.error(`[Timer] Auto-pick failed: ${result.error}`);
return false;
}
return true;
} catch (error) {
logger.error("[Timer] Error in triggerAutoPick:", error);
return false;
}
}
// Core scheduling logic — called after acquiring the schedulingInProgress lock.
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause - 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>
2026-06-05 22:42:19 -07:00
// 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> {
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause - 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>
2026-06-05 22:42:19 -07:00
const MAX_CONSECUTIVE_AUTOPICKS = 100; // safety cap against infinite loops on data bugs
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause - 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>
2026-06-05 22:42:19 -07:00
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;
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause - 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>
2026-06-05 22:42:19 -07:00
const draftSlots = await getDraftSlotsCached(seasonId);
const totalTeams = draftSlots.length;
if (totalTeams === 0) return;
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause - 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>
2026-06-05 22:42:19 -07:00
const currentPickNumber = season.currentPickNumber ?? 1;
const { pickInRound } = calculatePickInfo(currentPickNumber, totalTeams);
const currentDraftSlot = draftSlots.find((s) => s.draftOrder === pickInRound);
if (!currentDraftSlot) return;
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause - 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>
2026-06-05 22:42:19 -07:00
const currentTeamId = currentDraftSlot.teamId;
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause - 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>
2026-06-05 22:42:19 -07:00
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";
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
}
Refactor autodraft chain logic and improve timer handling (#25) * Fix draft timer missing after rollback causing draft to freeze The rollback endpoint was deleting all timers but only recreating one for the team currently on the clock. After that team made their pick, the next team had no timer row, causing the timer system to log "No timer found" and skip indefinitely, freezing the draft. Fix: recreate timers for ALL teams after a rollback, each starting at draftInitialTime. Also add a defensive fallback in the timer system: if a timer row is missing for the current team during an active draft, create it at draftInitialTime instead of skipping, so the draft can never get permanently stuck due to a missing timer. https://claude.ai/code/session_01YPxgcQywG57KiXj7m46gLV * Fix rollback to not touch timers at all Timers are each team's time bank and should not be affected by rolling back a pick. The previous code (both original and the first fix) was deleting all timer rows during rollback, which was the actual root cause of the missing timer bug. Rollback now only does what it should: delete picks from the rollback point onwards and reset currentPickNumber. The timer system will naturally resume for the correct team on its next tick. Also removes the incorrect timer-update socket emit that was sending initialTime instead of the team's actual remaining bank. https://claude.ai/code/session_01YPxgcQywG57KiXj7m46gLV * Fix all code review issues across timer, rollback, start, and draft-utils server/timer.ts: - Remove noisy per-tick console.log that fired every second per active draft - Remove slot:any type cast (Drizzle query results are already typed) - Fix inconsistent autodraftSettings argument in the === 0 trigger path to match the <= 0 path (pass null when autodraft is disabled) - Fix infinite auto-pick loop: triggerAutoPick now returns boolean; on failure the draft is paused and a draft-paused socket event is emitted so clients and commissioners are notified. "Pick already made" (race condition) is treated as success, not failure. draft.start.ts: - Validate that draft slots exist before updating season status to draft. Previously a missing-slots error left the season stuck in draft status with no timer rows. draft.rollback.ts: - Guard against empty draftSlots before performing snake draft math, which would produce Infinity/NaN with zero teams. draft-utils.ts: - Convert checkAndTriggerNextAutodraft from recursive to iterative. The mutual recursion (executeAutoPick → checkAndTriggerNextAutodraft → executeAutoPick) is now a while loop, preventing unbounded call stack growth in all-autodraft leagues. Add chainEnabled param to executeAutoPick so chain calls skip spawning a second chain. - Restructure getTopAvailableParticipant so the single-sport query is only built when actually needed (not thrown away in the multi-sport branch). - Update misleading timeUsed comment to accurately describe that the stored value is the team's bank balance at pick time, not time spent. https://claude.ai/code/session_01YPxgcQywG57KiXj7m46gLV --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 17:02:35 -08:00
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause - 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>
2026-06-05 22:42:19 -07:00
// 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;
}
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause - 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>
2026-06-05 22:42:19 -07:00
// 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)
),
});
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause - 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>
2026-06-05 22:42:19 -07:00
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,
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause - 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>
2026-06-05 22:42:19 -07:00
timeRemaining: initialTime,
picksExpiresAt: expiresAt,
picksStartedAt: startedAt,
});
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause - 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>
2026-06-05 22:42:19 -07:00
// 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
}
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause - 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>
2026-06-05 22:42:19 -07:00
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)
)
);
}
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause - 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>
2026-06-05 22:42:19 -07:00
const msUntilExpiry = expiresAt.getTime() - now;
const timeRemaining = Math.max(0, Math.ceil(msUntilExpiry / 1000));
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause - 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>
2026-06-05 22:42:19 -07:00
// 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; }
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause - 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>
2026-06-05 22:42:19 -07:00
continue;
}
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause - 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>
2026-06-05 22:42:19 -07:00
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
}
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause - 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>
2026-06-05 22:42:19 -07:00
logger.error(`[Timer] Hit max consecutive auto-picks (${MAX_CONSECUTIVE_AUTOPICKS}) for season ${seasonId}`);
}
async function schedulePickForSeason(seasonId: string): Promise<void> {
if (schedulingInProgress.has(seasonId)) return;
schedulingInProgress.add(seasonId);
try {
await _schedulePickForSeason(seasonId);
} catch (err) {
logger.error(`[Timer] schedulePickForSeason error for ${seasonId}:`, err);
} finally {
schedulingInProgress.delete(seasonId);
}
}
async function scheduleAllActiveDrafts(): Promise<void> {
await checkAndAutoStartDrafts();
const activeDrafts = await db.query.seasons.findMany({
where: eq(schema.seasons.status, "draft"),
});
// Evict caches for seasons no longer drafting.
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause - 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>
2026-06-05 22:42:19 -07:00
// 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));
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause - 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>
2026-06-05 22:42:19 -07:00
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) {
// Only schedule if not already scheduled (avoids duplicate timeouts).
if (!pickTimeouts.has(season.id) && !overnightResumeTimeouts.has(season.id)) {
await schedulePickForSeason(season.id);
}
}
}
export function startDraftTimerSystem(): void {
if (recoveryInterval) {
logger.log("[Timer] Timer system already running");
return;
}
// Schedule any in-progress drafts immediately on startup.
scheduleAllActiveDrafts().catch((err) =>
logger.error("[Timer] Error during startup scheduling:", err)
);
// Recovery interval: re-schedule any draft whose in-memory timeout was lost
// (process restart, overnight pause end, etc.). Runs every 30 s, not every 1 s.
recoveryInterval = setInterval(() => {
scheduleAllActiveDrafts().catch((err) =>
logger.error("[Timer] Error in recovery interval:", err)
);
}, 30_000);
logger.log("[Timer] Draft timer system started (event-driven)");
}
export function stopDraftTimerSystem(): void {
if (recoveryInterval) {
clearInterval(recoveryInterval);
recoveryInterval = null;
}
for (const t of pickTimeouts.values()) clearTimeout(t);
pickTimeouts.clear();
for (const t of overnightResumeTimeouts.values()) clearTimeout(t);
overnightResumeTimeouts.clear();
schedulingInProgress.clear();
draftSlotsCache.clear();
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause - 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>
2026-06-05 22:42:19 -07:00
clearAllOvernightPauseCaches();
logger.log("[Timer] Draft timer system stopped");
}
/**
* Called by pick routes after a pick is made so the timer immediately
* reschedules for the next team rather than waiting for the recovery interval.
*/
export async function rescheduleTimer(seasonId: string): Promise<void> {
cancelPickTimeout(seasonId);
cancelOvernightResumeTimeout(seasonId);
await schedulePickForSeason(seasonId);
}
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause - 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>
2026-06-05 22:42:19 -07:00
/**
* 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));
}