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