brackt/server/timer.ts
Chris Parsons 0ba6a2a408 Add auto-start draft at scheduled time (#437)
Commissioners can now opt in to having a draft start automatically at
its scheduled draftDateTime rather than requiring a manual "Start Draft"
click. The 1-second timer loop checks for eligible seasons each tick and
calls the new shared startDraft() service, which is also used by the
existing commissioner HTTP route.

- Add autoStartDraft boolean to seasons table (migration 0108)
- Extract core start logic into app/services/draft-autostart.ts so both
  the API route and the timer can call it without AsyncLocalStorage
- Add checkAndAutoStartDrafts() to the timer tick; disables autoStartDraft
  and stops retrying if no draft order is set at fire time
- Guard against null draftDateTime in both the timer query (isNotNull)
  and server actions (autoStartDraft forced false when datetime is null)
- Add "Auto-start at scheduled time" checkbox to league creation wizard
  (step 3) and league settings, gated on date+time being set
- Show countdown + "Start Now" button in draft room when auto-start is
  scheduled; reuses the existing nowForPauseCheck 1-second ticker
- Show orange warning banner on league homepage to commissioners when
  auto-start is within 1 hour and no draft order has been configured

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 23:31:51 -07:00

380 lines
13 KiB
TypeScript

import * as schema from "~/database/schema";
import { eq, and, asc, sql, lte, isNotNull } from "drizzle-orm";
import type { InferSelectModel } from "drizzle-orm";
import { getSocketIO } from "./socket";
import { executeAutoPick, calculatePickInfo } from "~/models/draft-utils";
import { isInOvernightWindow, getOvernightResumeUTC } from "~/lib/overnight-pause";
import { logger } from "./logger";
import { db } from "./db";
import { startDraft } from "~/services/draft-autostart";
let timerInterval: NodeJS.Timeout | null = null;
let timerTickRunning = false;
// Draft slots never change during an active draft — cache them to avoid
// a redundant query on every tick.
const draftSlotsCache = new Map<string, { teamId: string; draftOrder: number }[]>();
// Team timezone cache for per_user overnight pause mode (seasonId → teamId → timezone).
// Populated lazily; evicted when the 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) {
// ownerId column is varchar; if any row still holds a non-UUID value
// (pre-migration remnant) the ::uuid cast throws. Degrade gracefully.
logger.error("[Timer] getTeamTimezone cast failed, skipping overnight check:", err);
seasonMap = new Map();
}
teamTimezoneCache.set(seasonId, seasonMap);
}
return seasonMap.get(teamId) ?? null;
}
/**
* Determine whether the current team's pick is in an overnight pause window.
* Returns { active: false } or { active: true, resumesAtUTC: number }.
*/
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 {
// per_user: use the team owner's timezone, fall back to league timezone
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 };
}
/**
* Start the draft timer system
* Runs every second to update all active draft timers
*/
export function startDraftTimerSystem(): void {
if (timerInterval) {
logger.log("[Timer] Timer system already running");
return;
}
timerInterval = setInterval(async () => {
// Skip this tick if the previous one is still running (e.g. long autodraft chain)
// to prevent concurrent ticks from racing on the same pick slot.
if (timerTickRunning) return;
timerTickRunning = true;
try {
await updateDraftTimers();
} catch (error) {
logger.error("[Timer] Error updating draft timers:", error);
} finally {
timerTickRunning = false;
}
}, 1000);
logger.log("[Timer] Draft timer system started");
}
/**
* Stop the draft timer system
*/
export function stopDraftTimerSystem(): void {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
logger.log("[Timer] Draft timer system stopped");
}
}
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") {
// Disable auto-start to stop the retry loop. The commissioner will see
// the setting is now off and can re-enable it once the order is set.
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`);
}
}
}
/**
* Update all active draft timers
* Called every second by the timer interval
*/
async function updateDraftTimers(): Promise<void> {
await checkAndAutoStartDrafts();
const io = getSocketIO();
// Get all active drafts
const activeDrafts = await db.query.seasons.findMany({
where: eq(schema.seasons.status, "draft"),
});
if (activeDrafts.length === 0) {
draftSlotsCache.clear();
return;
}
// Evict cache entries for seasons no longer actively drafting.
const activeIds = new Set(activeDrafts.map((s) => s.id));
for (const cachedId of draftSlotsCache.keys()) {
if (!activeIds.has(cachedId)) draftSlotsCache.delete(cachedId);
}
for (const cachedId of teamTimezoneCache.keys()) {
if (!activeIds.has(cachedId)) teamTimezoneCache.delete(cachedId);
}
for (const season of activeDrafts) {
// Skip if draft is paused
if (season.draftPaused) {
continue;
}
const currentPickNumber = season.currentPickNumber ?? 1;
// Draft slots never change during an active draft — use the cache.
let draftSlots = draftSlotsCache.get(season.id);
if (!draftSlots) {
draftSlots = await db.query.draftSlots.findMany({
where: eq(schema.draftSlots.seasonId, season.id),
orderBy: asc(schema.draftSlots.draftOrder),
});
draftSlotsCache.set(season.id, draftSlots);
}
const totalTeams = draftSlots.length;
if (totalTeams === 0) continue;
const { pickInRound } = calculatePickInfo(currentPickNumber, totalTeams);
const currentDraftSlot = draftSlots.find(
(slot) => slot.draftOrder === pickInRound
);
if (!currentDraftSlot) {
continue;
}
const currentTeamId = currentDraftSlot.teamId;
// Get current team's timer
const timer = await db.query.draftTimers.findFirst({
where: and(
eq(schema.draftTimers.seasonId, season.id),
eq(schema.draftTimers.teamId, currentTeamId)
),
});
if (!timer) {
logger.warn(
`[Timer] No timer found for team ${currentTeamId} in season ${season.id}, creating with initial time`
);
// Standard mode seeds with the per-pick time; chess clock seeds with the full bank.
const initialTime = season.draftTimerMode === "standard"
? (season.draftIncrementTime || 30)
: (season.draftInitialTime || 120);
await db
.insert(schema.draftTimers)
.values({
seasonId: season.id,
teamId: currentTeamId,
timeRemaining: initialTime,
});
// Emit timer update so clients are aware of the new timer
io.to(`draft-${season.id}`).emit("timer-update", {
seasonId: season.id,
teamId: currentTeamId,
timeRemaining: initialTime,
currentPickNumber,
});
// Continue processing with the newly created timer on the next tick
continue;
}
// Fetch autodraft settings once — used both for while_on bypass and timer-expiry path.
const autodraftSettings = await db.query.autodraftSettings.findFirst({
where: and(
eq(schema.autodraftSettings.seasonId, season.id),
eq(schema.autodraftSettings.teamId, currentTeamId)
),
});
const shouldAutodraft = autodraftSettings?.isEnabled ?? false;
// while_on means "pick immediately when it's my turn" — bypass the countdown.
const isWhileOn = shouldAutodraft && autodraftSettings?.mode === "while_on";
// Overnight pause: freeze timer for non-autodraft teams in their overnight window.
const overnightPause = await checkOvernightPause(season, currentTeamId);
const isOvernightFreeze = overnightPause.active && !shouldAutodraft;
// Trigger pick when: timer expired OR team is in while_on autodraft mode.
// The chain picks consecutive while_on teams after each pick, but it is capped at
// totalTeams iterations. The while_on bypass here fills the gap: the timer picks
// the next while_on team within one tick (≤1 s) rather than waiting for the full
// countdown to expire. If the chain already made this pick, executeAutoPick
// returns "Pick already made" which triggerAutoPick treats as a non-fatal no-op.
if (timer.timeRemaining <= 0 || isWhileOn) {
if (timer.timeRemaining <= 0) {
logger.log(
`[Timer] ⚠️ Timer expired for team ${currentTeamId} in season ${season.id} (pick ${currentPickNumber})`
);
} else {
logger.log(
`[Timer] ⚡ while_on autodraft — immediate pick for team ${currentTeamId} in season ${season.id} (pick ${currentPickNumber})`
);
// Emit 0 so clients see the timer hit zero before the pick-made event arrives.
io.to(`draft-${season.id}`).emit("timer-update", {
seasonId: season.id,
teamId: currentTeamId,
timeRemaining: 0,
currentPickNumber,
overnightPauseActive: false,
});
}
const success = await triggerAutoPick(season.id, currentTeamId, currentPickNumber, shouldAutodraft ? (autodraftSettings ?? null) : null);
if (!success) {
logger.error(`[Timer] Pausing draft ${season.id} — auto-pick failed for team ${currentTeamId} pick ${currentPickNumber}`);
await db.update(schema.seasons).set({ draftPaused: true }).where(eq(schema.seasons.id, season.id));
io.to(`draft-${season.id}`).emit("draft-paused", { seasonId: season.id, paused: true });
}
continue;
}
// Overnight freeze: skip decrement, notify clients to display the pause indicator.
if (isOvernightFreeze) {
io.to(`draft-${season.id}`).emit("timer-update", {
seasonId: season.id,
teamId: currentTeamId,
timeRemaining: timer.timeRemaining,
currentPickNumber,
overnightPauseActive: true,
resumesAtUTC: overnightPause.resumesAtUTC,
});
continue;
}
// Atomically decrement timer (race-condition safe: uses DB-level update
// so concurrent increments from pick handlers are never overwritten)
const [updatedTimer] = await db
.update(schema.draftTimers)
.set({
timeRemaining: sql`GREATEST(${schema.draftTimers.timeRemaining} - 1, 0)`,
updatedAt: new Date(),
})
.where(eq(schema.draftTimers.id, timer.id))
.returning();
const newTimeRemaining = updatedTimer?.timeRemaining ?? 0;
// Emit timer update to all clients in the draft room
io.to(`draft-${season.id}`).emit("timer-update", {
seasonId: season.id,
teamId: currentTeamId,
timeRemaining: newTimeRemaining,
currentPickNumber,
overnightPauseActive: false,
});
// If timer just hit 0, trigger auto-pick (next_pick mode or no autodraft)
if (newTimeRemaining === 0) {
const success = await triggerAutoPick(season.id, currentTeamId, currentPickNumber, shouldAutodraft ? (autodraftSettings ?? null) : null);
if (!success) {
logger.error(`[Timer] Pausing draft ${season.id} — auto-pick failed for team ${currentTeamId} pick ${currentPickNumber}`);
await db.update(schema.seasons).set({ draftPaused: true }).where(eq(schema.seasons.id, season.id));
io.to(`draft-${season.id}`).emit("draft-paused", { seasonId: season.id, paused: true });
}
}
}
}
/**
* Trigger an automatic pick when timer expires.
* Returns true if the pick succeeded (or was already made by another path),
* false if a real failure occurred that requires commissioner intervention.
*/
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) {
// A race condition where the pick was already made is not a real failure
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;
}
}