Compare commits
2 commits
0eb6715655
...
46f8552f60
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46f8552f60 | ||
|
|
f36480c828 |
24 changed files with 7436 additions and 732 deletions
|
|
@ -35,6 +35,7 @@ interface UseDraftSocketEventsParams {
|
||||||
setConnectedTeams: (fn: (prev: Set<string>) => Set<string>) => void;
|
setConnectedTeams: (fn: (prev: Set<string>) => Set<string>) => void;
|
||||||
setQueue: (fn: (prev: QueueItem[]) => QueueItem[]) => void;
|
setQueue: (fn: (prev: QueueItem[]) => QueueItem[]) => void;
|
||||||
setTeamTimers: (fn: (prev: Record<string, number>) => Record<string, number>) => void;
|
setTeamTimers: (fn: (prev: Record<string, number>) => Record<string, number>) => void;
|
||||||
|
setPickTimerExpiresAt: (state: { teamId: string; expiresAt: number } | null) => void;
|
||||||
setIsOvernightPause: (value: boolean) => void;
|
setIsOvernightPause: (value: boolean) => void;
|
||||||
setOvernightResumesAt: (value: Date | null) => void;
|
setOvernightResumesAt: (value: Date | null) => void;
|
||||||
setWatchedParticipantIds: (value: Set<string>) => void;
|
setWatchedParticipantIds: (value: Set<string>) => void;
|
||||||
|
|
@ -65,6 +66,7 @@ export function useDraftSocketEvents({
|
||||||
setConnectedTeams,
|
setConnectedTeams,
|
||||||
setQueue,
|
setQueue,
|
||||||
setTeamTimers,
|
setTeamTimers,
|
||||||
|
setPickTimerExpiresAt,
|
||||||
setIsOvernightPause,
|
setIsOvernightPause,
|
||||||
setOvernightResumesAt,
|
setOvernightResumesAt,
|
||||||
setWatchedParticipantIds,
|
setWatchedParticipantIds,
|
||||||
|
|
@ -80,6 +82,8 @@ export function useDraftSocketEvents({
|
||||||
isDraftComplete?: boolean;
|
isDraftComplete?: boolean;
|
||||||
};
|
};
|
||||||
const handlePickMade = (data: PickMadePayload) => {
|
const handlePickMade = (data: PickMadePayload) => {
|
||||||
|
// Stop any running countdown — timer-pick-started for the next team will restart it.
|
||||||
|
setPickTimerExpiresAt(null);
|
||||||
if (isRevalidatingRef.current) {
|
if (isRevalidatingRef.current) {
|
||||||
pendingPicksDuringRevalidationRef.current.push(data.pick);
|
pendingPicksDuringRevalidationRef.current.push(data.pick);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -108,26 +112,38 @@ export function useDraftSocketEvents({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTimerUpdate = (data: {
|
const handleTimerPickStarted = (data: {
|
||||||
|
seasonId: string;
|
||||||
teamId: string;
|
teamId: string;
|
||||||
|
pickNumber: number;
|
||||||
|
expiresAt: number;
|
||||||
timeRemaining: number;
|
timeRemaining: number;
|
||||||
currentPickNumber: number | null;
|
|
||||||
overnightPauseActive?: boolean;
|
|
||||||
resumesAtUTC?: number;
|
|
||||||
}) => {
|
}) => {
|
||||||
setTeamTimers((prev) => {
|
setCurrentPick(data.pickNumber);
|
||||||
if (prev[data.teamId] === data.timeRemaining) return prev;
|
setTeamTimers((prev) => ({ ...prev, [data.teamId]: data.timeRemaining }));
|
||||||
return { ...prev, [data.teamId]: data.timeRemaining };
|
setPickTimerExpiresAt({ teamId: data.teamId, expiresAt: data.expiresAt });
|
||||||
});
|
setIsOvernightPause(false);
|
||||||
if (data.currentPickNumber !== null) {
|
setOvernightResumesAt(null);
|
||||||
setCurrentPick(data.currentPickNumber);
|
|
||||||
}
|
|
||||||
const pauseActive = data.overnightPauseActive ?? false;
|
|
||||||
setIsOvernightPause(pauseActive);
|
|
||||||
setOvernightResumesAt(pauseActive && data.resumesAtUTC ? new Date(data.resumesAtUTC) : null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDraftPaused = () => setIsPaused(true);
|
const handleTimerBankUpdated = (data: { teamId: string; timeRemaining: number }) => {
|
||||||
|
setTeamTimers((prev) => ({ ...prev, [data.teamId]: data.timeRemaining }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimerOvernightPaused = (data: {
|
||||||
|
seasonId: string;
|
||||||
|
teamId: string;
|
||||||
|
resumesAtUTC?: number;
|
||||||
|
}) => {
|
||||||
|
setPickTimerExpiresAt(null);
|
||||||
|
setIsOvernightPause(true);
|
||||||
|
setOvernightResumesAt(data.resumesAtUTC ? new Date(data.resumesAtUTC) : null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDraftPaused = () => {
|
||||||
|
setIsPaused(true);
|
||||||
|
setPickTimerExpiresAt(null);
|
||||||
|
};
|
||||||
const handleDraftResumed = () => setIsPaused(false);
|
const handleDraftResumed = () => setIsPaused(false);
|
||||||
const handleDraftCompleted = () => setIsDraftComplete(true);
|
const handleDraftCompleted = () => setIsDraftComplete(true);
|
||||||
|
|
||||||
|
|
@ -202,7 +218,8 @@ export function useDraftSocketEvents({
|
||||||
setPicks((prev) => prev.filter((p) => p.pickNumber < data.pickNumber));
|
setPicks((prev) => prev.filter((p) => p.pickNumber < data.pickNumber));
|
||||||
setCurrentPick(data.pickNumber);
|
setCurrentPick(data.pickNumber);
|
||||||
setIsDraftComplete(false);
|
setIsDraftComplete(false);
|
||||||
setIsPaused(false);
|
setIsPaused(true);
|
||||||
|
setPickTimerExpiresAt(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDraftStateSync = (data: {
|
const handleDraftStateSync = (data: {
|
||||||
|
|
@ -210,7 +227,9 @@ export function useDraftSocketEvents({
|
||||||
currentPickNumber: number;
|
currentPickNumber: number;
|
||||||
isPaused: boolean;
|
isPaused: boolean;
|
||||||
status: string;
|
status: string;
|
||||||
timers?: Array<{ teamId: string; timeRemaining: number }>;
|
isOvernightPause?: boolean;
|
||||||
|
overnightResumesAt?: number;
|
||||||
|
timers?: Array<{ teamId: string; timeRemaining: number; expiresAt?: number }>;
|
||||||
queue?: QueueItem[];
|
queue?: QueueItem[];
|
||||||
watchlistParticipantIds?: string[];
|
watchlistParticipantIds?: string[];
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -225,6 +244,24 @@ export function useDraftSocketEvents({
|
||||||
data.timers?.forEach((t) => { updated[t.teamId] = t.timeRemaining; });
|
data.timers?.forEach((t) => { updated[t.teamId] = t.timeRemaining; });
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
// Restore 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) {
|
if (data.queue) {
|
||||||
const q = data.queue;
|
const q = data.queue;
|
||||||
|
|
@ -258,7 +295,9 @@ export function useDraftSocketEvents({
|
||||||
};
|
};
|
||||||
|
|
||||||
on("pick-made", handlePickMade as (data: unknown) => void);
|
on("pick-made", handlePickMade as (data: unknown) => void);
|
||||||
on("timer-update", handleTimerUpdate 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);
|
on("draft-paused", handleDraftPaused as (data: unknown) => void);
|
||||||
on("draft-resumed", handleDraftResumed as (data: unknown) => void);
|
on("draft-resumed", handleDraftResumed as (data: unknown) => void);
|
||||||
on("draft-completed", handleDraftCompleted as (data: unknown) => void);
|
on("draft-completed", handleDraftCompleted as (data: unknown) => void);
|
||||||
|
|
@ -278,7 +317,9 @@ export function useDraftSocketEvents({
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
off("pick-made", handlePickMade as (data: unknown) => void);
|
off("pick-made", handlePickMade as (data: unknown) => void);
|
||||||
off("timer-update", handleTimerUpdate 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);
|
off("draft-paused", handleDraftPaused as (data: unknown) => void);
|
||||||
off("draft-resumed", handleDraftResumed as (data: unknown) => void);
|
off("draft-resumed", handleDraftResumed as (data: unknown) => void);
|
||||||
off("draft-completed", handleDraftCompleted as (data: unknown) => void);
|
off("draft-completed", handleDraftCompleted as (data: unknown) => void);
|
||||||
|
|
|
||||||
|
|
@ -187,12 +187,12 @@ describe("executeAutoPick — timer mode behavior", () => {
|
||||||
// ── chess_clock mode ────────────────────────────────────────────────────────
|
// ── chess_clock mode ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("chess_clock mode", () => {
|
describe("chess_clock mode", () => {
|
||||||
it("emits timer-update with bank + increment after a timer-triggered pick", async () => {
|
it("writes exactly increment to DB timer when timer expired (timeRemainingAtPick = 0)", async () => {
|
||||||
|
// Default mock has timeRemaining: 0 and picksExpiresAt: null (expired timer)
|
||||||
const mockDb = makeMockDb({ draftTimerMode: "chess_clock", draftIncrementTime: 15 });
|
const mockDb = makeMockDb({ draftTimerMode: "chess_clock", draftIncrementTime: 15 });
|
||||||
// Timer expired at 0; 0 + 15 = 15 after the increment
|
|
||||||
mockDb.returning
|
mockDb.returning
|
||||||
.mockResolvedValueOnce([mockDraftPick]) // insert draft pick
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 15 }]); // update timer
|
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 15 }]);
|
||||||
|
|
||||||
await executeAutoPick({
|
await executeAutoPick({
|
||||||
seasonId: SEASON_ID,
|
seasonId: SEASON_ID,
|
||||||
|
|
@ -203,9 +203,9 @@ describe("executeAutoPick — timer mode behavior", () => {
|
||||||
db: mockDb,
|
db: mockDb,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
// 0 remaining + 15 increment = 15; picksExpiresAt cleared so next turn starts fresh
|
||||||
"timer-update",
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 15 })
|
expect.objectContaining({ timeRemaining: 15, picksExpiresAt: null, picksStartedAt: null })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -245,9 +245,9 @@ describe("executeAutoPick — timer mode behavior", () => {
|
||||||
db: mockDb,
|
db: mockDb,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
// 0 remaining (expired) + 30 increment = 30
|
||||||
"timer-update",
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -273,7 +273,7 @@ describe("executeAutoPick — timer mode behavior", () => {
|
||||||
// ── standard mode ───────────────────────────────────────────────────────────
|
// ── standard mode ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("standard mode", () => {
|
describe("standard mode", () => {
|
||||||
it("emits timer-update with exactly draftIncrementTime after a timer-triggered pick", async () => {
|
it("resets timer to exactly draftIncrementTime in DB after a timer-triggered pick", async () => {
|
||||||
const mockDb = makeMockDb({ draftTimerMode: "standard", draftIncrementTime: 30 });
|
const mockDb = makeMockDb({ draftTimerMode: "standard", draftIncrementTime: 30 });
|
||||||
mockDb.returning
|
mockDb.returning
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
.mockResolvedValueOnce([mockDraftPick])
|
||||||
|
|
@ -288,9 +288,8 @@ describe("executeAutoPick — timer mode behavior", () => {
|
||||||
db: mockDb,
|
db: mockDb,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
"timer-update",
|
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -327,9 +326,8 @@ describe("executeAutoPick — timer mode behavior", () => {
|
||||||
db: mockDb,
|
db: mockDb,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
"timer-update",
|
expect.objectContaining({ timeRemaining: 90, picksExpiresAt: null, picksStartedAt: null })
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 90 })
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq, and, notInArray, desc, inArray, sql, asc } from "drizzle-orm";
|
import { eq, and, notInArray, desc, inArray, asc } from "drizzle-orm";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import type { InferSelectModel } from "drizzle-orm";
|
import type { InferSelectModel } from "drizzle-orm";
|
||||||
import { getTeamQueue, getAllQueuesForSeason } from "./draft-queue";
|
import { getTeamQueue, getAllQueuesForSeason } from "./draft-queue";
|
||||||
|
|
@ -471,6 +471,7 @@ export async function executeAutoPick(params: {
|
||||||
autodraftSettings?: AutodraftSettings | null;
|
autodraftSettings?: AutodraftSettings | null;
|
||||||
db?: ReturnType<typeof database>;
|
db?: ReturnType<typeof database>;
|
||||||
chainEnabled?: boolean; // Set to false when called from within the autodraft chain to prevent recursion
|
chainEnabled?: boolean; // Set to false when called from within the autodraft chain to prevent recursion
|
||||||
|
pickMadeAt?: number; // ms timestamp; pass from route handler for accurate remaining-time credit
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|
@ -491,9 +492,13 @@ export async function executeAutoPick(params: {
|
||||||
commissionerUserId,
|
commissionerUserId,
|
||||||
autodraftSettings,
|
autodraftSettings,
|
||||||
db: providedDb,
|
db: providedDb,
|
||||||
|
pickMadeAt: callerPickMadeAt,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
const db = providedDb || database();
|
const db = providedDb || database();
|
||||||
|
// Use caller-provided timestamp when available (route handler captures it before auth/DB overhead).
|
||||||
|
// Fall back to now for timer-triggered paths where the callback fires at the expiry instant.
|
||||||
|
const pickMadeAt = callerPickMadeAt ?? Date.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Race condition protection - check if pick already made
|
// Race condition protection - check if pick already made
|
||||||
|
|
@ -524,6 +529,10 @@ export async function executeAutoPick(params: {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (season.draftPaused) {
|
||||||
|
return { success: false, error: "Draft is paused" };
|
||||||
|
}
|
||||||
|
|
||||||
// Get draft slots to calculate round/pickInRound and get all team IDs
|
// Get draft slots to calculate round/pickInRound and get all team IDs
|
||||||
const draftSlots = await db.query.draftSlots.findMany({
|
const draftSlots = await db.query.draftSlots.findMany({
|
||||||
where: eq(schema.draftSlots.seasonId, seasonId),
|
where: eq(schema.draftSlots.seasonId, seasonId),
|
||||||
|
|
@ -611,7 +620,8 @@ export async function executeAutoPick(params: {
|
||||||
? (commissionerUserId || "")
|
? (commissionerUserId || "")
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
// Fetch current timer before pick so we have the time remaining at decision point
|
// Fetch current timer before pick so we have the time remaining at decision point.
|
||||||
|
// Use picksExpiresAt (wall-clock truth) rather than the stale timeRemaining column.
|
||||||
const incrementTime = season.draftIncrementTime || 30;
|
const incrementTime = season.draftIncrementTime || 30;
|
||||||
const currentTimer = await db.query.draftTimers.findFirst({
|
const currentTimer = await db.query.draftTimers.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
|
|
@ -624,6 +634,17 @@ export async function executeAutoPick(params: {
|
||||||
logger.warn(`[AutoPick] No timer found for team ${teamId} in season ${seasonId}`);
|
logger.warn(`[AutoPick] No timer found for team ${teamId} in season ${seasonId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Three cases:
|
||||||
|
// - picksExpiresAt set, in the future: pick was made early → credit ceiling of remaining seconds
|
||||||
|
// (Math.ceil matches the client display, which also uses ceil)
|
||||||
|
// - picksExpiresAt set, expired: timer fired → 0 (pickMadeAt >= picksExpiresAt for timer path)
|
||||||
|
// - picksExpiresAt null: timer not running this turn (while_on, bank-depleted) → use timeRemaining
|
||||||
|
const timeRemainingAtPick = (() => {
|
||||||
|
if (!currentTimer?.picksExpiresAt) return currentTimer?.timeRemaining ?? 0;
|
||||||
|
const msRemaining = currentTimer.picksExpiresAt.getTime() - pickMadeAt;
|
||||||
|
return msRemaining > 0 ? Math.ceil(msRemaining / 1000) : 0;
|
||||||
|
})();
|
||||||
|
|
||||||
// Create the draft pick — use ON CONFLICT DO NOTHING so that concurrent timer
|
// Create the draft pick — use ON CONFLICT DO NOTHING so that concurrent timer
|
||||||
// ticks racing to the same pick slot are handled atomically at the DB level
|
// ticks racing to the same pick slot are handled atomically at the DB level
|
||||||
// rather than relying on the TOCTOU pre-check above.
|
// rather than relying on the TOCTOU pre-check above.
|
||||||
|
|
@ -638,8 +659,8 @@ export async function executeAutoPick(params: {
|
||||||
pickInRound,
|
pickInRound,
|
||||||
pickedByUserId,
|
pickedByUserId,
|
||||||
pickedByType: "auto",
|
pickedByType: "auto",
|
||||||
// Records the team's bank balance at the moment the pick was made (seconds remaining)
|
// Records the team's actual remaining time at the moment the pick was made
|
||||||
timeUsed: currentTimer ? currentTimer.timeRemaining : undefined,
|
timeUsed: currentTimer ? timeRemainingAtPick : undefined,
|
||||||
})
|
})
|
||||||
.onConflictDoNothing()
|
.onConflictDoNothing()
|
||||||
.returning();
|
.returning();
|
||||||
|
|
@ -660,63 +681,32 @@ export async function executeAutoPick(params: {
|
||||||
const isDraftComplete = nextPickNumber > totalPicks;
|
const isDraftComplete = nextPickNumber > totalPicks;
|
||||||
|
|
||||||
// Update the team's timer after the auto-pick.
|
// Update the team's timer after the auto-pick.
|
||||||
// Standard mode: reset to the per-pick time (atomic, prevents race with timer loop).
|
// Standard mode: reset to the per-pick time.
|
||||||
// Chess clock mode: add the increment so the team starts their next turn with some time
|
// Chess clock: credit actual remaining time + increment. When the timer fired, timeRemainingAtPick
|
||||||
// (without this, a single timeout would permanently freeze their bank at 0).
|
// is 0, so the team gets exactly incrementTime — their bank is not accidentally refilled.
|
||||||
let emitTimeRemaining: number;
|
// Also clear picksExpiresAt/picksStartedAt so _schedulePickForSeason starts fresh next turn.
|
||||||
|
const newTimeRemaining = season.draftTimerMode === "standard"
|
||||||
|
? incrementTime
|
||||||
|
: timeRemainingAtPick + incrementTime;
|
||||||
|
|
||||||
if (season.draftTimerMode === "standard") {
|
const [updatedTimer] = await db
|
||||||
const [updatedTimer] = await db
|
.update(schema.draftTimers)
|
||||||
.update(schema.draftTimers)
|
.set({
|
||||||
.set({ timeRemaining: sql`${incrementTime}`, updatedAt: new Date() })
|
timeRemaining: newTimeRemaining,
|
||||||
.where(
|
picksExpiresAt: null as Date | null,
|
||||||
and(
|
picksStartedAt: null as Date | null,
|
||||||
eq(schema.draftTimers.seasonId, seasonId),
|
updatedAt: new Date(),
|
||||||
eq(schema.draftTimers.teamId, teamId)
|
})
|
||||||
)
|
.where(and(eq(schema.draftTimers.seasonId, seasonId), eq(schema.draftTimers.teamId, teamId)))
|
||||||
)
|
.returning();
|
||||||
.returning();
|
|
||||||
emitTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
|
if (!updatedTimer) {
|
||||||
logger.log(
|
await db.insert(schema.draftTimers).values({ seasonId, teamId, timeRemaining: newTimeRemaining });
|
||||||
`[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)`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
logger.log(
|
||||||
getSocketIO().to(`draft-${seasonId}`).emit("timer-update", {
|
`[AutoPick] Timer for team ${teamId}: ${newTimeRemaining}s (${season.draftTimerMode} mode, +${incrementTime}s increment)`
|
||||||
seasonId,
|
);
|
||||||
teamId,
|
|
||||||
timeRemaining: emitTimeRemaining,
|
|
||||||
currentPickNumber: nextPickNumber,
|
|
||||||
overnightPauseActive: false,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("[AutoPick] Socket.IO timer-update error:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next team's timer is unchanged — their bank carries forward as-is
|
|
||||||
|
|
||||||
// Update season's current pick number
|
// Update season's current pick number
|
||||||
await db
|
await db
|
||||||
|
|
@ -808,6 +798,10 @@ export async function executeAutoPick(params: {
|
||||||
isDraftComplete,
|
isDraftComplete,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Emit timer bank update AFTER pick-made so handlePickMade stops the countdown
|
||||||
|
// interval first — otherwise the interval could tick 0 and overwrite the new bank.
|
||||||
|
io.to(`draft-${seasonId}`).emit("timer-bank-updated", { teamId, timeRemaining: newTimeRemaining });
|
||||||
|
|
||||||
// Emit draft-completed event if applicable
|
// Emit draft-completed event if applicable
|
||||||
if (isDraftComplete) {
|
if (isDraftComplete) {
|
||||||
io.to(`draft-${seasonId}`).emit("draft-completed");
|
io.to(`draft-${seasonId}`).emit("draft-completed");
|
||||||
|
|
@ -817,16 +811,15 @@ export async function executeAutoPick(params: {
|
||||||
logger.error("[AutoPick] Socket.IO events error:", error);
|
logger.error("[AutoPick] Socket.IO events error:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recompute Brackt EV/VORP after this pick is committed so autopick paths
|
// Recompute Brackt EV/VORP after this pick is committed. Fire-and-forget so it
|
||||||
// (force-autopick, timer, user autodraft) are not one pick behind.
|
// doesn't delay schedulePickForSeason (and therefore timer-pick-started) for the next team.
|
||||||
try {
|
runBracktHarvilleForFantasySeason(seasonId, db)
|
||||||
const updates = await runBracktHarvilleForFantasySeason(seasonId, db);
|
.then((updates) => {
|
||||||
if (updates.length > 0) {
|
if (updates.length > 0) {
|
||||||
getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates });
|
getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
})
|
||||||
logger.error("[AutoPick] Error updating Brackt EVs after pick:", error);
|
.catch((error) => logger.error("[AutoPick] Error updating Brackt EVs after pick:", error));
|
||||||
}
|
|
||||||
|
|
||||||
// Announce before triggering the chain so Discord messages arrive in pick-number
|
// Announce before triggering the chain so Discord messages arrive in pick-number
|
||||||
// order. If the chain ran first, chained picks would announce before this one.
|
// order. If the chain ran first, chained picks would announce before this one.
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ vi.mock("~/models/user", () => ({
|
||||||
vi.mock("~/models/audit-log", () => ({
|
vi.mock("~/models/audit-log", () => ({
|
||||||
logCommissionerAction: vi.fn().mockResolvedValue(undefined),
|
logCommissionerAction: vi.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("~/server/timer", () => ({
|
||||||
|
rescheduleTimer: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -367,27 +370,18 @@ describe("draft.force-manual-pick action", () => {
|
||||||
describe("chess_clock mode", () => {
|
describe("chess_clock mode", () => {
|
||||||
it("adds the increment to the picking team's bank", async () => {
|
it("adds the increment to the picking team's bank", async () => {
|
||||||
// Timer has 75s (from beforeEach mock); 75 + 30 = 105s after increment
|
// Timer has 75s (from beforeEach mock); 75 + 30 = 105s after increment
|
||||||
mockDb.returning
|
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105 }]);
|
|
||||||
|
|
||||||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
"timer-update",
|
expect.objectContaining({ timeRemaining: 105, picksExpiresAt: null, picksStartedAt: null })
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("writes the incremented timer to the DB", async () => {
|
it("writes the incremented timer to the DB", async () => {
|
||||||
mockDb.returning
|
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 105 }]);
|
|
||||||
|
|
||||||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(mockDb.set).toHaveBeenCalledWith(
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
|
expect.objectContaining({ timeRemaining: expect.any(Number), picksExpiresAt: null, updatedAt: expect.any(Date) })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -401,41 +395,30 @@ describe("draft.force-manual-pick action", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resets the picking team's timer to draftIncrementTime", async () => {
|
it("resets the picking team's timer to draftIncrementTime", async () => {
|
||||||
mockDb.returning
|
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 30 }]);
|
|
||||||
|
|
||||||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
"timer-update",
|
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("writes the timer reset to the DB", async () => {
|
it("writes the timer reset to the DB", async () => {
|
||||||
mockDb.returning
|
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 30 }]);
|
|
||||||
|
|
||||||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(mockDb.set).toHaveBeenCalledWith(
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
|
expect.objectContaining({ timeRemaining: expect.any(Number), picksExpiresAt: null, updatedAt: expect.any(Date) })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── REGRESSION: Next team's timer must never be touched ──────────────────
|
// ── REGRESSION: rescheduleTimer should be called exactly once per pick ──────
|
||||||
|
|
||||||
it("REGRESSION: does not emit a timer-update for the next team", async () => {
|
it("REGRESSION: calls rescheduleTimer exactly once after a pick", async () => {
|
||||||
|
const { rescheduleTimer } = await import("~/server/timer");
|
||||||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
const nextTeamTimerEmits = mockSocketIO.emit.mock.calls.filter(
|
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledTimes(1);
|
||||||
([event, payload]: [string, any]) =>
|
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||||
event === "timer-update" && payload?.teamId === NEXT_TEAM_ID
|
|
||||||
);
|
|
||||||
expect(nextTeamTimerEmits).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import type { RouterContextProvider } from "react-router";
|
import type { RouterContextProvider } from "react-router";
|
||||||
import { action } from "~/routes/api/draft.force-manual-pick";
|
import { action } from "~/routes/api/draft.force-manual-pick";
|
||||||
|
import { rescheduleTimer } from "~/server/timer";
|
||||||
|
|
||||||
const ctx = {} as unknown as RouterContextProvider;
|
const ctx = {} as unknown as RouterContextProvider;
|
||||||
|
|
||||||
|
|
@ -41,6 +42,9 @@ vi.mock("~/models/user", () => ({
|
||||||
vi.mock("~/models/audit-log", () => ({
|
vi.mock("~/models/audit-log", () => ({
|
||||||
logCommissionerAction: vi.fn().mockResolvedValue(undefined),
|
logCommissionerAction: vi.fn().mockResolvedValue(undefined),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("~/server/timer", () => ({
|
||||||
|
rescheduleTimer: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -192,46 +196,35 @@ describe("draft.force-manual-pick action — timer mode behavior", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("commissioner force pick", () => {
|
describe("commissioner force pick", () => {
|
||||||
it("emits timer-update with bank + increment", async () => {
|
it("stores bank + increment in DB timer and calls rescheduleTimer", async () => {
|
||||||
// Pre-pick bank: 75s. Expected after increment: 75 + 30 = 105s.
|
// Pre-pick bank: 75s (no picksExpiresAt). 75 + 30 = 105.
|
||||||
mockDb.returning
|
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
"timer-update",
|
expect.objectContaining({ timeRemaining: 105, picksExpiresAt: null, picksStartedAt: null })
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
|
|
||||||
);
|
);
|
||||||
|
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("writes the timer update to the DB", async () => {
|
it("writes the timer update to the DB", async () => {
|
||||||
mockDb.returning
|
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
// Two DB updates: timer row + season.currentPickNumber
|
// Two DB updates: timer row + season.currentPickNumber
|
||||||
expect(mockDb.set).toHaveBeenCalledWith(
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
|
expect.objectContaining({ timeRemaining: expect.any(Number), picksExpiresAt: null, updatedAt: expect.any(Date) })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accumulates a larger bank when more time was remaining", async () => {
|
it("accumulates a larger bank when more time was remaining", async () => {
|
||||||
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 100 });
|
|
||||||
// 100 + 30 = 130
|
// 100 + 30 = 130
|
||||||
mockDb.returning
|
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 100 });
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 130 }]);
|
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
"timer-update",
|
expect.objectContaining({ timeRemaining: 130, picksExpiresAt: null, picksStartedAt: null })
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 130 })
|
|
||||||
);
|
);
|
||||||
|
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -247,28 +240,20 @@ describe("draft.force-manual-pick action — timer mode behavior", () => {
|
||||||
mockDb.query.commissioners.findFirst.mockResolvedValue(null);
|
mockDb.query.commissioners.findFirst.mockResolvedValue(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits timer-update with bank + increment", async () => {
|
it("stores bank + increment in DB timer and calls rescheduleTimer", async () => {
|
||||||
mockDb.returning
|
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
|
||||||
"timer-update",
|
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("writes the timer update to the DB", async () => {
|
|
||||||
mockDb.returning
|
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(mockDb.set).toHaveBeenCalledWith(
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
|
expect.objectContaining({ timeRemaining: 105, picksExpiresAt: null, picksStartedAt: null })
|
||||||
|
);
|
||||||
|
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the timer update to the DB", async () => {
|
||||||
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ timeRemaining: expect.any(Number), picksExpiresAt: null, updatedAt: expect.any(Date) })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -287,30 +272,23 @@ describe("draft.force-manual-pick action — timer mode behavior", () => {
|
||||||
it("resets bank to exactly draftIncrementTime", async () => {
|
it("resets bank to exactly draftIncrementTime", async () => {
|
||||||
// Pre-pick bank doesn't matter — standard always resets
|
// Pre-pick bank doesn't matter — standard always resets
|
||||||
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 5 });
|
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 5 });
|
||||||
mockDb.returning
|
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
|
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
"timer-update",
|
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
|
||||||
);
|
);
|
||||||
|
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not accumulate time even when bank was large", async () => {
|
it("does not accumulate time even when bank was large", async () => {
|
||||||
// Team had 25s left; standard mode resets to increment, never adds to prior balance
|
// Team had 25s left; standard mode resets to increment, never adds to prior balance
|
||||||
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 25 });
|
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 25 });
|
||||||
mockDb.returning
|
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
|
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
"timer-update",
|
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -327,32 +305,25 @@ describe("draft.force-manual-pick action — timer mode behavior", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resets bank to exactly draftIncrementTime", async () => {
|
it("resets bank to exactly draftIncrementTime", async () => {
|
||||||
mockDb.returning
|
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
|
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
"timer-update",
|
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
|
||||||
);
|
);
|
||||||
|
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses custom draftIncrementTime when configured", async () => {
|
it("uses custom draftIncrementTime when configured", async () => {
|
||||||
mockDb.query.seasons.findFirst.mockResolvedValue(
|
mockDb.query.seasons.findFirst.mockResolvedValue(
|
||||||
makeSeason({ draftTimerMode: "standard", draftInitialTime: 60, draftIncrementTime: 60 })
|
makeSeason({ draftTimerMode: "standard", draftInitialTime: 60, draftIncrementTime: 60 })
|
||||||
);
|
);
|
||||||
mockDb.returning
|
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 60 }]);
|
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
"timer-update",
|
expect.objectContaining({ timeRemaining: 60, picksExpiresAt: null, picksStartedAt: null })
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 60 })
|
|
||||||
);
|
);
|
||||||
|
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import type { RouterContextProvider } from "react-router";
|
import type { RouterContextProvider } from "react-router";
|
||||||
import { action } from "~/routes/api/draft.make-pick";
|
import { action } from "~/routes/api/draft.make-pick";
|
||||||
|
import { rescheduleTimer } from "~/server/timer";
|
||||||
|
|
||||||
const ctx = {} as unknown as RouterContextProvider;
|
const ctx = {} as unknown as RouterContextProvider;
|
||||||
|
|
||||||
|
|
@ -39,6 +40,9 @@ vi.mock("~/models/draft-utils", () => ({
|
||||||
vi.mock("~/models/user", () => ({
|
vi.mock("~/models/user", () => ({
|
||||||
isUserAdmin: vi.fn(),
|
isUserAdmin: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("~/server/timer", () => ({
|
||||||
|
rescheduleTimer: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -188,44 +192,33 @@ describe("draft.make-pick action — timer mode behavior", () => {
|
||||||
describe("owner pick", () => {
|
describe("owner pick", () => {
|
||||||
// Auth default is OWNER_ID (team owner), no extra setup needed.
|
// Auth default is OWNER_ID (team owner), no extra setup needed.
|
||||||
|
|
||||||
it("emits timer-update with bank + increment", async () => {
|
it("stores bank + increment in DB timer and calls rescheduleTimer", async () => {
|
||||||
// DB returns 75 + 30 = 105 after the atomic add
|
// Pre-pick bank: 75s (no picksExpiresAt, falls back to timeRemaining). 75 + 30 = 105.
|
||||||
mockDb.returning
|
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
"timer-update",
|
expect.objectContaining({ timeRemaining: 105, picksExpiresAt: null, picksStartedAt: null })
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
|
|
||||||
);
|
);
|
||||||
|
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accumulates a larger bank when more time was remaining", async () => {
|
it("accumulates a larger bank when more time was remaining", async () => {
|
||||||
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 100 });
|
|
||||||
// 100 + 30 = 130
|
// 100 + 30 = 130
|
||||||
mockDb.returning
|
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 100 });
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 130 }]);
|
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
|
||||||
"timer-update",
|
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 130 })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("writes the timer update to the DB", async () => {
|
|
||||||
mockDb.returning
|
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(mockDb.set).toHaveBeenCalledWith(
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
|
expect.objectContaining({ timeRemaining: 130, picksExpiresAt: null, picksStartedAt: null })
|
||||||
|
);
|
||||||
|
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the timer update to the DB", async () => {
|
||||||
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ timeRemaining: expect.any(Number), picksExpiresAt: null, updatedAt: expect.any(Date) })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -246,28 +239,20 @@ describe("draft.make-pick action — timer mode behavior", () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits timer-update with bank + increment", async () => {
|
it("stores bank + increment in DB timer and calls rescheduleTimer", async () => {
|
||||||
mockDb.returning
|
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
|
||||||
"timer-update",
|
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("writes the timer update to the DB", async () => {
|
|
||||||
mockDb.returning
|
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(mockDb.set).toHaveBeenCalledWith(
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
|
expect.objectContaining({ timeRemaining: 105, picksExpiresAt: null, picksStartedAt: null })
|
||||||
|
);
|
||||||
|
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the timer update to the DB", async () => {
|
||||||
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ timeRemaining: expect.any(Number), picksExpiresAt: null, updatedAt: expect.any(Date) })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -290,28 +275,20 @@ describe("draft.make-pick action — timer mode behavior", () => {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits timer-update with bank + increment", async () => {
|
it("stores bank + increment in DB timer and calls rescheduleTimer", async () => {
|
||||||
mockDb.returning
|
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
|
||||||
"timer-update",
|
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("writes the timer update to the DB", async () => {
|
|
||||||
mockDb.returning
|
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(mockDb.set).toHaveBeenCalledWith(
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ timeRemaining: expect.anything(), updatedAt: expect.any(Date) })
|
expect.objectContaining({ timeRemaining: 105, picksExpiresAt: null, picksStartedAt: null })
|
||||||
|
);
|
||||||
|
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes the timer update to the DB", async () => {
|
||||||
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ timeRemaining: expect.any(Number), picksExpiresAt: null, updatedAt: expect.any(Date) })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -327,30 +304,22 @@ describe("draft.make-pick action — timer mode behavior", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("owner pick — resets bank to exactly draftIncrementTime", async () => {
|
it("owner pick — resets bank to exactly draftIncrementTime", async () => {
|
||||||
mockDb.returning
|
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
|
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
"timer-update",
|
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
|
||||||
);
|
);
|
||||||
|
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("owner pick — fast picker does NOT accumulate time (bank never exceeds increment)", async () => {
|
it("owner pick — fast picker does NOT accumulate time (bank never exceeds increment)", async () => {
|
||||||
// Team had 25s left (picked quickly); standard mode ignores prior balance
|
// Team had 25s left (picked quickly); standard mode ignores prior balance
|
||||||
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 25 });
|
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 25 });
|
||||||
mockDb.returning
|
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
|
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
"timer-update",
|
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -363,16 +332,12 @@ describe("draft.make-pick action — timer mode behavior", () => {
|
||||||
mockDraftSlots[1],
|
mockDraftSlots[1],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
mockDb.returning
|
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
|
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
"timer-update",
|
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
|
||||||
);
|
);
|
||||||
|
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("admin pick — resets bank to exactly draftIncrementTime", async () => {
|
it("admin pick — resets bank to exactly draftIncrementTime", async () => {
|
||||||
|
|
@ -385,32 +350,25 @@ describe("draft.make-pick action — timer mode behavior", () => {
|
||||||
mockDraftSlots[1],
|
mockDraftSlots[1],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
mockDb.returning
|
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
|
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
"timer-update",
|
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
|
||||||
);
|
);
|
||||||
|
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses custom draftIncrementTime when configured", async () => {
|
it("uses custom draftIncrementTime when configured", async () => {
|
||||||
mockDb.query.seasons.findFirst.mockResolvedValue(
|
mockDb.query.seasons.findFirst.mockResolvedValue(
|
||||||
makeSeason({ draftTimerMode: "standard", draftInitialTime: 90, draftIncrementTime: 90 })
|
makeSeason({ draftTimerMode: "standard", draftInitialTime: 90, draftIncrementTime: 90 })
|
||||||
);
|
);
|
||||||
mockDb.returning
|
|
||||||
.mockResolvedValueOnce([mockDraftPick])
|
|
||||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 90 }]);
|
|
||||||
|
|
||||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
expect(mockDb.set).toHaveBeenCalledWith(
|
||||||
"timer-update",
|
expect.objectContaining({ timeRemaining: 90, picksExpiresAt: null, picksStartedAt: null })
|
||||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 90 })
|
|
||||||
);
|
);
|
||||||
|
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import * as schema from "~/database/schema";
|
||||||
import { isCommissioner } from "~/models/commissioner";
|
import { isCommissioner } from "~/models/commissioner";
|
||||||
import { logCommissionerAction } from "~/models/audit-log";
|
import { logCommissionerAction } from "~/models/audit-log";
|
||||||
import { getSocketIO } from "../../../server/socket";
|
import { getSocketIO } from "../../../server/socket";
|
||||||
|
import { rescheduleTimer } from "../../../server/timer";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
|
|
||||||
import type { ActionFunctionArgs } from "react-router";
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
|
|
@ -59,6 +60,7 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
);
|
);
|
||||||
|
|
||||||
let newTime: number;
|
let newTime: number;
|
||||||
|
let isOnClock = false;
|
||||||
|
|
||||||
if (!currentTimer) {
|
if (!currentTimer) {
|
||||||
if (adjustment <= 0) {
|
if (adjustment <= 0) {
|
||||||
|
|
@ -71,11 +73,31 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
timeRemaining: newTime,
|
timeRemaining: newTime,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
newTime = Math.max(0, currentTimer.timeRemaining + adjustment);
|
const now = Date.now();
|
||||||
await db
|
if (currentTimer.picksExpiresAt && currentTimer.picksExpiresAt.getTime() > now) {
|
||||||
.update(schema.draftTimers)
|
// Team is on the clock — base the adjustment on the live expiry, not the stale timeRemaining.
|
||||||
.set({ timeRemaining: newTime, updatedAt: new Date() })
|
isOnClock = true;
|
||||||
.where(eq(schema.draftTimers.id, currentTimer.id));
|
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({
|
const team = await db.query.teams.findFirst({
|
||||||
|
|
@ -96,17 +118,21 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
if (isOnClock) {
|
||||||
getSocketIO()
|
// Reschedule cancels the old setTimeout and re-reads picksExpiresAt from the DB,
|
||||||
.to(`draft-${seasonId}`)
|
// then emits timer-pick-started with the new expiresAt so all clients update their countdown.
|
||||||
.emit("timer-update", {
|
try {
|
||||||
seasonId,
|
await rescheduleTimer(seasonId);
|
||||||
teamId,
|
} catch (err) {
|
||||||
timeRemaining: newTime,
|
logger.error("[AdjustTimeBank] rescheduleTimer failed:", err);
|
||||||
currentPickNumber: season.currentPickNumber ?? 1,
|
}
|
||||||
});
|
} else {
|
||||||
} catch (error) {
|
// Off-clock team: just push the updated bank to all clients.
|
||||||
logger.error("Socket.IO error:", error);
|
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 });
|
return Response.json({ success: true, timeRemaining: newTime });
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,11 @@ import { eq } from "drizzle-orm";
|
||||||
import { executeAutoPick } from "~/models/draft-utils";
|
import { executeAutoPick } from "~/models/draft-utils";
|
||||||
import { isCommissioner } from "~/models/commissioner";
|
import { isCommissioner } from "~/models/commissioner";
|
||||||
import { logCommissionerAction } from "~/models/audit-log";
|
import { logCommissionerAction } from "~/models/audit-log";
|
||||||
|
import { rescheduleTimer } from "../../../server/timer";
|
||||||
|
|
||||||
import type { ActionFunctionArgs } from "react-router";
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
export async function action(args: ActionFunctionArgs) {
|
export async function action(args: ActionFunctionArgs) {
|
||||||
|
const pickMadeAt = Date.now(); // capture before any async work for accurate timer credit
|
||||||
const { request } = args;
|
const { request } = args;
|
||||||
const session = await auth.api.getSession({ headers: args.request.headers });
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
const userId = session?.user.id ?? null;
|
const userId = session?.user.id ?? null;
|
||||||
|
|
@ -49,12 +51,19 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
triggeredBy: "commissioner",
|
triggeredBy: "commissioner",
|
||||||
commissionerUserId: userId,
|
commissionerUserId: userId,
|
||||||
db,
|
db,
|
||||||
|
pickMadeAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return Response.json({ error: result.error }, { status: 400 });
|
return Response.json({ error: result.error }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await rescheduleTimer(seasonId);
|
||||||
|
} catch {
|
||||||
|
// Non-fatal — recovery interval will pick it up within 30 s
|
||||||
|
}
|
||||||
|
|
||||||
const team = await db.query.teams.findFirst({
|
const team = await db.query.teams.findFirst({
|
||||||
where: eq(schema.teams.id, teamId),
|
where: eq(schema.teams.id, teamId),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { auth } from "~/lib/auth.server";
|
import { auth } from "~/lib/auth.server";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq, and, sql } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { isUserAdmin } from "~/models/user";
|
import { isUserAdmin } from "~/models/user";
|
||||||
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
||||||
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
|
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
|
||||||
|
|
@ -13,11 +13,13 @@ import { enqueuePickNotification } from "~/services/discord";
|
||||||
import { notifyPickMadeOnDiscord } from "~/services/draft-discord.server";
|
import { notifyPickMadeOnDiscord } from "~/services/draft-discord.server";
|
||||||
import { logCommissionerAction } from "~/models/audit-log";
|
import { logCommissionerAction } from "~/models/audit-log";
|
||||||
import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket";
|
import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket";
|
||||||
|
import { rescheduleTimer } from "../../../server/timer";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
|
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
|
||||||
import type { ActionFunctionArgs } from "react-router";
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
|
|
||||||
export async function action(args: ActionFunctionArgs) {
|
export async function action(args: ActionFunctionArgs) {
|
||||||
|
const pickMadeAt = Date.now(); // capture before any async work for accurate timer credit
|
||||||
const { request } = args;
|
const { request } = args;
|
||||||
const session = await auth.api.getSession({ headers: args.request.headers });
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
const userId = session?.user.id ?? null;
|
const userId = session?.user.id ?? null;
|
||||||
|
|
@ -173,41 +175,30 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
const isDraftComplete = nextPickNumber > totalPicks;
|
const isDraftComplete = nextPickNumber > totalPicks;
|
||||||
|
|
||||||
// Standard mode: reset to the per-pick time (so the next turn starts fresh).
|
// Standard mode: reset to the per-pick time (so the next turn starts fresh).
|
||||||
// Chess clock: add the increment to the bank (same as any other pick type).
|
// Chess clock: add the increment to the actual remaining time (from picksExpiresAt).
|
||||||
|
// Also clear picksExpiresAt so the timer system knows this team's turn is over.
|
||||||
const incrementTime = season.draftIncrementTime || 30;
|
const incrementTime = season.draftIncrementTime || 30;
|
||||||
const timerSql = season.draftTimerMode === "standard"
|
const timerSnapshot = await db.query.draftTimers.findFirst({
|
||||||
? sql`${incrementTime}`
|
where: and(eq(schema.draftTimers.seasonId, seasonId), eq(schema.draftTimers.teamId, teamId)),
|
||||||
: sql`${schema.draftTimers.timeRemaining} + ${incrementTime}`;
|
});
|
||||||
|
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;
|
||||||
|
|
||||||
const [updatedTimer] = await db
|
const [updatedTimer] = await db
|
||||||
.update(schema.draftTimers)
|
.update(schema.draftTimers)
|
||||||
.set({ timeRemaining: timerSql, updatedAt: new Date() })
|
.set({ timeRemaining: newTimeRemaining, picksExpiresAt: null, picksStartedAt: null, updatedAt: new Date() })
|
||||||
.where(
|
.where(and(eq(schema.draftTimers.seasonId, seasonId), eq(schema.draftTimers.teamId, teamId)))
|
||||||
and(
|
|
||||||
eq(schema.draftTimers.seasonId, seasonId),
|
|
||||||
eq(schema.draftTimers.teamId, teamId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.returning();
|
.returning();
|
||||||
const newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
|
|
||||||
if (!updatedTimer) {
|
if (!updatedTimer) {
|
||||||
await db.insert(schema.draftTimers).values({ seasonId, teamId, timeRemaining: newTimeRemaining });
|
await db.insert(schema.draftTimers).values({ seasonId, teamId, timeRemaining: newTimeRemaining });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit timer update to all clients
|
// rescheduleTimer (called below) will emit timer-pick-started for the next team.
|
||||||
try {
|
|
||||||
getSocketIO().to(`draft-${seasonId}`).emit("timer-update", {
|
|
||||||
seasonId,
|
|
||||||
teamId,
|
|
||||||
timeRemaining: newTimeRemaining,
|
|
||||||
currentPickNumber: nextPickNumber,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Socket.IO timer-update error:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next team's timer is unchanged — their bank carries forward as-is.
|
|
||||||
// The timer server loop will start counting down from their existing balance.
|
|
||||||
|
|
||||||
// Update season's current pick number
|
// Update season's current pick number
|
||||||
await db
|
await db
|
||||||
|
|
@ -257,6 +248,9 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
isDraftComplete,
|
isDraftComplete,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Emit timer bank update AFTER pick-made so handlePickMade stops the countdown first.
|
||||||
|
io.to(`draft-${seasonId}`).emit("timer-bank-updated", { teamId, timeRemaining: newTimeRemaining });
|
||||||
|
|
||||||
if (isDraftComplete) {
|
if (isDraftComplete) {
|
||||||
io.to(`draft-${seasonId}`).emit("draft-completed");
|
io.to(`draft-${seasonId}`).emit("draft-completed");
|
||||||
scheduleDraftRoomClosure(seasonId);
|
scheduleDraftRoomClosure(seasonId);
|
||||||
|
|
@ -301,14 +295,13 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
|
|
||||||
// Check if next team has autodraft enabled and trigger immediately
|
// Check if next team has autodraft enabled and trigger immediately
|
||||||
if (!isDraftComplete) {
|
if (!isDraftComplete) {
|
||||||
try {
|
runBracktHarvilleForFantasySeason(seasonId, db)
|
||||||
const updates = await runBracktHarvilleForFantasySeason(seasonId, db);
|
.then((updates) => {
|
||||||
if (updates.length > 0) {
|
if (updates.length > 0) {
|
||||||
getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates });
|
getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
})
|
||||||
logger.error("Brackt EV update after forced manual pick failed:", error);
|
.catch((error) => logger.error("Brackt EV update after forced manual pick failed:", error));
|
||||||
}
|
|
||||||
|
|
||||||
const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) });
|
const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) });
|
||||||
if (!freshSeason?.draftPaused) {
|
if (!freshSeason?.draftPaused) {
|
||||||
|
|
@ -322,6 +315,13 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reschedule the timer for the next team on the clock (runs after the full autodraft chain).
|
||||||
|
try {
|
||||||
|
await rescheduleTimer(seasonId);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("[ForceManualPick] rescheduleTimer failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
success: true,
|
success: true,
|
||||||
pick: draftPick,
|
pick: draftPick,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { auth } from "~/lib/auth.server";
|
||||||
import { getTeamForPick } from "~/lib/draft-order";
|
import { getTeamForPick } from "~/lib/draft-order";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq, and, sql } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { isUserAdmin } from "~/models/user";
|
import { isUserAdmin } from "~/models/user";
|
||||||
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
||||||
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
|
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
|
||||||
|
|
@ -10,6 +10,7 @@ import { getParticipantsForSeasonWithSports } from "~/models/season-participant"
|
||||||
import { getSeasonSportsSimple } from "~/models/season-sport";
|
import { getSeasonSportsSimple } from "~/models/season-sport";
|
||||||
import { calculatePickInfo, checkAndTriggerNextAutodraft, pruneIneligibleQueueItems } from "~/models/draft-utils";
|
import { calculatePickInfo, checkAndTriggerNextAutodraft, pruneIneligibleQueueItems } from "~/models/draft-utils";
|
||||||
import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket";
|
import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket";
|
||||||
|
import { rescheduleTimer } from "../../../server/timer";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
|
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
|
||||||
import { sendOnTheClockEmail } from "~/services/draft-email.server";
|
import { sendOnTheClockEmail } from "~/services/draft-email.server";
|
||||||
|
|
@ -18,6 +19,7 @@ import { enqueuePickNotification } from "~/services/discord";
|
||||||
|
|
||||||
import type { ActionFunctionArgs } from "react-router";
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
export async function action(args: ActionFunctionArgs) {
|
export async function action(args: ActionFunctionArgs) {
|
||||||
|
const pickMadeAt = Date.now(); // capture before any async work for accurate timer credit
|
||||||
const { request } = args;
|
const { request } = args;
|
||||||
const session = await auth.api.getSession({ headers: args.request.headers });
|
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||||
const userId = session?.user.id ?? null;
|
const userId = session?.user.id ?? null;
|
||||||
|
|
@ -141,13 +143,19 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
return Response.json({ error: reason }, { status: 400 });
|
return Response.json({ error: reason }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot the team's time bank before the pick (used for audit / pick history)
|
// Snapshot the team's time bank before the pick (used for audit / pick history).
|
||||||
|
// Use picksExpiresAt to compute actual remaining time rather than the stale timeRemaining.
|
||||||
const timerSnapshot = await db.query.draftTimers.findFirst({
|
const timerSnapshot = await db.query.draftTimers.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(schema.draftTimers.seasonId, seasonId),
|
eq(schema.draftTimers.seasonId, seasonId),
|
||||||
eq(schema.draftTimers.teamId, currentDraftSlot.teamId)
|
eq(schema.draftTimers.teamId, currentDraftSlot.teamId)
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
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
|
// Create the draft pick
|
||||||
const [draftPick] = await db
|
const [draftPick] = await db
|
||||||
|
|
@ -161,7 +169,7 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
pickInRound,
|
pickInRound,
|
||||||
pickedByUserId: userId,
|
pickedByUserId: userId,
|
||||||
pickedByType: isTeamOwner ? "owner" : commissionerRecord ? "commissioner" : "admin",
|
pickedByType: isTeamOwner ? "owner" : commissionerRecord ? "commissioner" : "admin",
|
||||||
timeUsed: timerSnapshot?.timeRemaining ?? 0,
|
timeUsed: timeRemainingAtPick,
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
@ -210,45 +218,33 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
const isDraftComplete = nextPickNumber > totalPicks;
|
const isDraftComplete = nextPickNumber > totalPicks;
|
||||||
|
|
||||||
// Update the picking team's timer after their pick.
|
// Update the picking team's timer after their pick.
|
||||||
// Standard mode: always reset to the per-pick time, regardless of who picked.
|
// Standard mode: always reset to the per-pick time.
|
||||||
// Chess clock: always add the increment (any pick type — owner, commissioner, or admin).
|
// Chess clock: add the increment to the actual remaining time (computed from picksExpiresAt).
|
||||||
|
// Also clear picksExpiresAt so the timer system knows this team's turn is over.
|
||||||
const incrementTime = season.draftIncrementTime || 30;
|
const incrementTime = season.draftIncrementTime || 30;
|
||||||
let newTimeRemaining: number;
|
const newTimeRemaining =
|
||||||
|
season.draftTimerMode === "standard"
|
||||||
|
? incrementTime
|
||||||
|
: timeRemainingAtPick + incrementTime;
|
||||||
|
|
||||||
let updatedTimer: { timeRemaining: number } | undefined;
|
const timerUpdateSet = {
|
||||||
|
timeRemaining: newTimeRemaining,
|
||||||
|
picksExpiresAt: null as Date | null,
|
||||||
|
picksStartedAt: null as Date | null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
if (season.draftTimerMode === "standard") {
|
const [updatedTimer] = await db
|
||||||
// Atomic reset so the timer loop cannot race with this write.
|
.update(schema.draftTimers)
|
||||||
[updatedTimer] = await db
|
.set(timerUpdateSet)
|
||||||
.update(schema.draftTimers)
|
.where(
|
||||||
.set({ timeRemaining: sql`${incrementTime}`, updatedAt: new Date() })
|
and(
|
||||||
.where(
|
eq(schema.draftTimers.seasonId, seasonId),
|
||||||
and(
|
eq(schema.draftTimers.teamId, currentDraftSlot.teamId)
|
||||||
eq(schema.draftTimers.seasonId, seasonId),
|
|
||||||
eq(schema.draftTimers.teamId, currentDraftSlot.teamId)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.returning();
|
)
|
||||||
newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
|
.returning();
|
||||||
} else {
|
|
||||||
// Chess clock: earn the increment regardless of who made the pick (atomic add).
|
|
||||||
[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, currentDraftSlot.teamId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the timer row didn't exist yet, seed it.
|
|
||||||
if (!updatedTimer) {
|
if (!updatedTimer) {
|
||||||
await db.insert(schema.draftTimers).values({
|
await db.insert(schema.draftTimers).values({
|
||||||
seasonId,
|
seasonId,
|
||||||
|
|
@ -257,20 +253,6 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
getSocketIO().to(`draft-${seasonId}`).emit("timer-update", {
|
|
||||||
seasonId,
|
|
||||||
teamId: currentDraftSlot.teamId,
|
|
||||||
timeRemaining: newTimeRemaining,
|
|
||||||
currentPickNumber: nextPickNumber,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Socket.IO timer-update error:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next team's timer is unchanged — their bank carries forward as-is
|
|
||||||
// (no emit needed; the timer system will start decrementing their existing bank)
|
|
||||||
|
|
||||||
// Update season's current pick number (AFTER initializing next timer to prevent race condition)
|
// Update season's current pick number (AFTER initializing next timer to prevent race condition)
|
||||||
await db
|
await db
|
||||||
.update(schema.seasons)
|
.update(schema.seasons)
|
||||||
|
|
@ -297,6 +279,13 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
isDraftComplete,
|
isDraftComplete,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Emit timer bank update AFTER pick-made so handlePickMade stops the countdown
|
||||||
|
// interval first — otherwise the interval could tick 0 and overwrite the new bank.
|
||||||
|
getSocketIO().to(`draft-${seasonId}`).emit("timer-bank-updated", {
|
||||||
|
teamId: currentDraftSlot.teamId,
|
||||||
|
timeRemaining: newTimeRemaining,
|
||||||
|
});
|
||||||
|
|
||||||
if (isDraftComplete) {
|
if (isDraftComplete) {
|
||||||
getSocketIO().to(`draft-${seasonId}`).emit("draft-completed");
|
getSocketIO().to(`draft-${seasonId}`).emit("draft-completed");
|
||||||
scheduleDraftRoomClosure(seasonId);
|
scheduleDraftRoomClosure(seasonId);
|
||||||
|
|
@ -327,14 +316,14 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
|
|
||||||
// Check if next team has autodraft enabled and trigger immediately
|
// Check if next team has autodraft enabled and trigger immediately
|
||||||
if (!isDraftComplete) {
|
if (!isDraftComplete) {
|
||||||
try {
|
// Fire-and-forget so it doesn't delay rescheduleTimer (and timer-pick-started) for the next team.
|
||||||
const updates = await runBracktHarvilleForFantasySeason(seasonId, db);
|
runBracktHarvilleForFantasySeason(seasonId, db)
|
||||||
if (updates.length > 0) {
|
.then((updates) => {
|
||||||
getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { 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);
|
})
|
||||||
}
|
.catch((error) => logger.error("Brackt EV update after pick failed:", error));
|
||||||
|
|
||||||
const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) });
|
const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) });
|
||||||
if (!freshSeason?.draftPaused) {
|
if (!freshSeason?.draftPaused) {
|
||||||
|
|
@ -353,6 +342,13 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
.catch((err) => logger.error("On-the-clock email failed:", err));
|
.catch((err) => logger.error("On-the-clock email failed:", err));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reschedule the timer for the next team on the clock (runs after the full autodraft chain).
|
||||||
|
try {
|
||||||
|
await rescheduleTimer(seasonId);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("[Pick] rescheduleTimer failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
success: true,
|
success: true,
|
||||||
pick: draftPick,
|
pick: draftPick,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import * as schema from "~/database/schema";
|
||||||
import { isCommissioner } from "~/models/commissioner";
|
import { isCommissioner } from "~/models/commissioner";
|
||||||
import { logCommissionerAction } from "~/models/audit-log";
|
import { logCommissionerAction } from "~/models/audit-log";
|
||||||
import { getSocketIO } from "../../../server/socket";
|
import { getSocketIO } from "../../../server/socket";
|
||||||
|
import { onDraftPaused } from "../../../server/timer";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
|
|
||||||
import type { ActionFunctionArgs } from "react-router";
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
|
|
@ -65,6 +66,13 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
details: { pickNumber: season.currentPickNumber },
|
details: { pickNumber: season.currentPickNumber },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cancel the in-memory timeout and snapshot remaining time so resume is accurate.
|
||||||
|
try {
|
||||||
|
await onDraftPaused(seasonId);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("[Pause] onDraftPaused failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
// Emit socket event
|
// Emit socket event
|
||||||
try {
|
try {
|
||||||
getSocketIO().to(`draft-${seasonId}`).emit("draft-paused", {
|
getSocketIO().to(`draft-${seasonId}`).emit("draft-paused", {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import * as schema from "~/database/schema";
|
||||||
import { isCommissioner } from "~/models/commissioner";
|
import { isCommissioner } from "~/models/commissioner";
|
||||||
import { logCommissionerAction } from "~/models/audit-log";
|
import { logCommissionerAction } from "~/models/audit-log";
|
||||||
import { getSocketIO } from "../../../server/socket";
|
import { getSocketIO } from "../../../server/socket";
|
||||||
|
import { rescheduleTimer } from "../../../server/timer";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
|
|
||||||
import type { ActionFunctionArgs } from "react-router";
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
|
|
@ -65,6 +66,13 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
details: { pickNumber: season.currentPickNumber },
|
details: { pickNumber: season.currentPickNumber },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Restart the timer from where it was frozen at pause time.
|
||||||
|
try {
|
||||||
|
await rescheduleTimer(seasonId);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("[Resume] rescheduleTimer failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
// Emit socket event
|
// Emit socket event
|
||||||
try {
|
try {
|
||||||
getSocketIO().to(`draft-${seasonId}`).emit("draft-resumed", {
|
getSocketIO().to(`draft-${seasonId}`).emit("draft-resumed", {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { eq, and, gte } from "drizzle-orm";
|
||||||
import { isCommissioner } from "~/models/commissioner";
|
import { isCommissioner } from "~/models/commissioner";
|
||||||
import { logCommissionerAction } from "~/models/audit-log";
|
import { logCommissionerAction } from "~/models/audit-log";
|
||||||
import { getSocketIO } from "../../../server/socket";
|
import { getSocketIO } from "../../../server/socket";
|
||||||
|
import { onDraftRolledBack } from "../../../server/timer";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
|
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
|
||||||
|
|
||||||
|
|
@ -77,12 +78,13 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update season: reset pick number and unpause
|
// Update season: reset pick number and pause so the commissioner can review
|
||||||
|
// before picks resume. They must explicitly hit Resume to restart the clock.
|
||||||
await db
|
await db
|
||||||
.update(schema.seasons)
|
.update(schema.seasons)
|
||||||
.set({
|
.set({
|
||||||
currentPickNumber: pickNumber,
|
currentPickNumber: pickNumber,
|
||||||
draftPaused: false,
|
draftPaused: true,
|
||||||
})
|
})
|
||||||
.where(eq(schema.seasons.id, seasonId));
|
.where(eq(schema.seasons.id, seasonId));
|
||||||
|
|
||||||
|
|
@ -99,7 +101,10 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
getSocketIO().to(`draft-${seasonId}`).emit("draft-rolled-back", {
|
const io = getSocketIO();
|
||||||
|
// Roll-back leaves the draft paused so the commissioner reviews before resuming.
|
||||||
|
io.to(`draft-${seasonId}`).emit("draft-paused", { seasonId, paused: true });
|
||||||
|
io.to(`draft-${seasonId}`).emit("draft-rolled-back", {
|
||||||
seasonId,
|
seasonId,
|
||||||
pickNumber,
|
pickNumber,
|
||||||
teamId: rollbackSlot?.teamId,
|
teamId: rollbackSlot?.teamId,
|
||||||
|
|
@ -117,5 +122,18 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
logger.error("[Rollback] Brackt EV update failed:", error);
|
logger.error("[Rollback] Brackt EV update failed:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset all timer rows to the initial bank and reschedule the clock from scratch.
|
||||||
|
// Timer rows are not rolled back by the picks delete above, so without this they
|
||||||
|
// would show banked values from the picks that were just deleted (e.g. 2:08, 2:07).
|
||||||
|
const initialTime =
|
||||||
|
season.draftTimerMode === "standard"
|
||||||
|
? (season.draftIncrementTime || 30)
|
||||||
|
: (season.draftInitialTime || 120);
|
||||||
|
try {
|
||||||
|
await onDraftRolledBack(seasonId, initialTime);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error("[Rollback] onDraftRolledBack failed:", err);
|
||||||
|
}
|
||||||
|
|
||||||
return Response.json({ success: true, pickNumber });
|
return Response.json({ success: true, pickNumber });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { isCommissioner } from "~/models/commissioner";
|
||||||
import { findUserById, getUserDisplayName } from "~/models/user";
|
import { findUserById, getUserDisplayName } from "~/models/user";
|
||||||
import { getSocketIO } from "../../../server/socket";
|
import { getSocketIO } from "../../../server/socket";
|
||||||
import { startDraft } from "~/services/draft-autostart";
|
import { startDraft } from "~/services/draft-autostart";
|
||||||
|
import { rescheduleTimer } from "../../../server/timer";
|
||||||
|
|
||||||
import type { ActionFunctionArgs } from "react-router";
|
import type { ActionFunctionArgs } from "react-router";
|
||||||
|
|
||||||
|
|
@ -58,5 +59,13 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
return Response.json({ error: result.error }, { status });
|
return Response.json({ error: result.error }, { status });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kick off the first pick's timer immediately rather than waiting for the
|
||||||
|
// 30-second recovery interval.
|
||||||
|
try {
|
||||||
|
await rescheduleTimer(seasonId);
|
||||||
|
} catch {
|
||||||
|
// Non-fatal — recovery interval will pick it up within 30 s
|
||||||
|
}
|
||||||
|
|
||||||
return Response.json({ success: true });
|
return Response.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -304,13 +304,20 @@ export default function DraftRoom() {
|
||||||
}, [season.status, season.autoStartDraft, season.draftDateTime, nowForPauseCheck]);
|
}, [season.status, season.autoStartDraft, season.draftDateTime, nowForPauseCheck]);
|
||||||
|
|
||||||
const draftBoardUrl = `/leagues/${season.leagueId}/draft-board/${season.id}`;
|
const draftBoardUrl = `/leagues/${season.leagueId}/draft-board/${season.id}`;
|
||||||
|
|
||||||
|
// Capture a client-side timestamp the moment isDraftComplete first becomes true.
|
||||||
|
// When the draft completes via socket event the loader hasn't revalidated yet,
|
||||||
|
// so season.draftCompletedAt is null and we need a fallback baseline for the countdown.
|
||||||
|
const draftCompletedAtClientRef = useRef<number | null>(null);
|
||||||
|
if (isDraftComplete && draftCompletedAtClientRef.current === null) {
|
||||||
|
draftCompletedAtClientRef.current = season.draftCompletedAt?.getTime() ?? Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
const roomClosureCountdown = useMemo(() => {
|
const roomClosureCountdown = useMemo(() => {
|
||||||
if (!isDraftComplete) return null;
|
if (!isDraftComplete) return null;
|
||||||
const completedAt = season.draftCompletedAt;
|
const baseMs = season.draftCompletedAt?.getTime() ?? draftCompletedAtClientRef.current ?? Date.now();
|
||||||
if (!completedAt) return 300;
|
const elapsed = (nowForPauseCheck.getTime() - baseMs) / 1000;
|
||||||
const elapsed = (nowForPauseCheck.getTime() - completedAt.getTime()) / 1000;
|
return Math.max(0, Math.ceil(300 - elapsed));
|
||||||
const remaining = Math.max(0, 300 - elapsed);
|
|
||||||
return Math.ceil(remaining);
|
|
||||||
}, [isDraftComplete, season.draftCompletedAt, nowForPauseCheck]);
|
}, [isDraftComplete, season.draftCompletedAt, nowForPauseCheck]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -493,6 +500,25 @@ export default function DraftRoom() {
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(() => () => { animationTimersRef.current.forEach(clearTimeout); }, []);
|
useEffect(() => () => { animationTimersRef.current.forEach(clearTimeout); }, []);
|
||||||
|
|
||||||
|
// Client-side countdown: updated every second from the server-provided expiresAt timestamp.
|
||||||
|
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() ?? 0 } : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pickTimerExpiresAt) return;
|
||||||
|
const { teamId, expiresAt } = pickTimerExpiresAt;
|
||||||
|
const tick = () => {
|
||||||
|
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, 500);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [pickTimerExpiresAt, setTeamTimers]);
|
||||||
|
|
||||||
useDraftSocketEvents({
|
useDraftSocketEvents({
|
||||||
on,
|
on,
|
||||||
off,
|
off,
|
||||||
|
|
@ -514,6 +540,7 @@ export default function DraftRoom() {
|
||||||
setConnectedTeams,
|
setConnectedTeams,
|
||||||
setQueue,
|
setQueue,
|
||||||
setTeamTimers,
|
setTeamTimers,
|
||||||
|
setPickTimerExpiresAt,
|
||||||
setIsOvernightPause,
|
setIsOvernightPause,
|
||||||
setOvernightResumesAt,
|
setOvernightResumesAt,
|
||||||
setWatchedParticipantIds,
|
setWatchedParticipantIds,
|
||||||
|
|
@ -530,6 +557,13 @@ export default function DraftRoom() {
|
||||||
}
|
}
|
||||||
}, [sidebarCollapsed]);
|
}, [sidebarCollapsed]);
|
||||||
|
|
||||||
|
// Revalidate loader data when the draft auto-starts so the pre-draft UI transitions without a refresh.
|
||||||
|
useEffect(() => {
|
||||||
|
const handleDraftStarted = () => revalidate();
|
||||||
|
on("draft-started", handleDraftStarted);
|
||||||
|
return () => off("draft-started", handleDraftStarted);
|
||||||
|
}, [on, off, revalidate]);
|
||||||
|
|
||||||
// Queue handlers
|
// Queue handlers
|
||||||
const handleAddToQueue = useCallback(async (participantId: string) => {
|
const handleAddToQueue = useCallback(async (participantId: string) => {
|
||||||
if (!userTeam) return;
|
if (!userTeam) return;
|
||||||
|
|
@ -1019,7 +1053,7 @@ export default function DraftRoom() {
|
||||||
const isMyTurn = !!(
|
const isMyTurn = !!(
|
||||||
userTeam && currentDraftSlot && currentDraftSlot.team.id === userTeam.id
|
userTeam && currentDraftSlot && currentDraftSlot.team.id === userTeam.id
|
||||||
);
|
);
|
||||||
const canPick = isMyTurn && season.status === "draft" && !isPaused; // Only team owner on their turn when draft is active
|
const canPick = isMyTurn && season.status === "draft" && !isPaused;
|
||||||
|
|
||||||
const currentDraftSlotOwnerName = currentDraftSlot ? ownerMap[currentDraftSlot.team.id] : undefined;
|
const currentDraftSlotOwnerName = currentDraftSlot ? ownerMap[currentDraftSlot.team.id] : undefined;
|
||||||
const currentClockTime = currentDraftSlot ? teamTimers[currentDraftSlot.team.id] : undefined;
|
const currentClockTime = currentDraftSlot ? teamTimers[currentDraftSlot.team.id] : undefined;
|
||||||
|
|
|
||||||
|
|
@ -326,7 +326,9 @@ export const draftTimers = pgTable("draft_timers", {
|
||||||
teamId: uuid("team_id")
|
teamId: uuid("team_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => teams.id, { onDelete: "cascade" }),
|
.references(() => teams.id, { onDelete: "cascade" }),
|
||||||
timeRemaining: integer("time_remaining").notNull(), // seconds
|
timeRemaining: integer("time_remaining").notNull(), // seconds (bank in chess_clock; reset value in standard)
|
||||||
|
picksExpiresAt: timestamp("picks_expires_at"), // when current pick's timer expires; null = not running
|
||||||
|
picksStartedAt: timestamp("picks_started_at"), // when current pick slot opened
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
2
drizzle/0114_panoramic_human_cannonball.sql
Normal file
2
drizzle/0114_panoramic_human_cannonball.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE "draft_timers" ADD COLUMN "picks_expires_at" timestamp;--> statement-breakpoint
|
||||||
|
ALTER TABLE "draft_timers" ADD COLUMN "picks_started_at" timestamp;
|
||||||
6205
drizzle/meta/0114_snapshot.json
Normal file
6205
drizzle/meta/0114_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -799,6 +799,13 @@
|
||||||
"when": 1779579877169,
|
"when": 1779579877169,
|
||||||
"tag": "0113_bent_banshee",
|
"tag": "0113_bent_banshee",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 114,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780454064143,
|
||||||
|
"tag": "0114_panoramic_human_cannonball",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
251
plans/zero-downtime-scaling.md
Normal file
251
plans/zero-downtime-scaling.md
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
# Plan: Zero-Downtime Deploys + Multi-Server Scaling
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Currently the draft timer (`server/timer.ts`) runs as a `setInterval` every second inside the web server process. This causes two problems:
|
||||||
|
|
||||||
|
1. **Deploys interrupt active drafts** — when the web server restarts during a rolling deploy, the timer stops, Socket.IO clients disconnect, and users see a "reconnecting" banner mid-draft.
|
||||||
|
2. **Cannot run multiple web server instances** — if two instances both run the timer tick, they independently decrement `timeRemaining` (2x speed) and both attempt to trigger autopicks on the same pick slot.
|
||||||
|
|
||||||
|
Additionally, `server/snapshots.ts` has the same per-instance `setInterval` problem (daily snapshots would be created multiple times).
|
||||||
|
|
||||||
|
There is no Redis, no distributed locking, and no graceful shutdown handler today.
|
||||||
|
|
||||||
|
## Three Goals (in priority order)
|
||||||
|
|
||||||
|
1. **Draft timer survives web server deploys** — timer lives in a separate worker process that isn't restarted when the web server is.
|
||||||
|
2. **Multiple web server instances can safely coexist** — Socket.IO state shared via Redis adapter; timer runs only once (in the worker).
|
||||||
|
3. **Cron jobs (standings sync, daily snapshots) run reliably once** — managed inside the same worker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Target
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐ Redis (pub/sub + ┌─────────────────────┐
|
||||||
|
│ Web Server(s) │ Socket.IO adapter) │ Timer Worker │
|
||||||
|
│ - HTTP / SSR │◄──────────────────────►│ - Draft timers │
|
||||||
|
│ - Socket.IO │ │ - Daily snapshots │
|
||||||
|
│ - React Router │ │ - Standings crons │
|
||||||
|
└─────────────────────┘ └─────────────────────┘
|
||||||
|
│ │
|
||||||
|
└───────────────────── PostgreSQL ──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Socket.IO uses `@socket.io/redis-adapter` so any instance can broadcast to any client room. The timer worker connects to the same Redis bus and emits into draft rooms — web servers relay those to connected clients.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Event-Driven Timer (stop per-second DB writes) ← START HERE ✅
|
||||||
|
|
||||||
|
**Goal**: replace the `setInterval(1000)` + per-second `UPDATE draftTimers SET timeRemaining = GREATEST(timeRemaining - 1, 0)` with a model where the server stores *when the timer expires* and uses a single `setTimeout`.
|
||||||
|
|
||||||
|
### 1a. DB schema change
|
||||||
|
|
||||||
|
Add two nullable columns to `draft_timers`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// database/schema.ts
|
||||||
|
picksExpiresAt: timestamp("picks_expires_at"), // null = paused or pick just made
|
||||||
|
picksStartedAt: timestamp("picks_started_at"), // when current pick slot opened
|
||||||
|
```
|
||||||
|
|
||||||
|
**Migration**: `npm run db:generate && npm run db:migrate`
|
||||||
|
|
||||||
|
Keep `timeRemaining` — it is still the authoritative bank value in chess-clock mode and is needed when pausing/unpausing to know how much time to restore.
|
||||||
|
|
||||||
|
### 1b. Timer logic rewrite (`server/timer.ts`)
|
||||||
|
|
||||||
|
Replace `setInterval(fn, 1000)` with a scheduler that:
|
||||||
|
|
||||||
|
1. **On startup**, reads all active draft seasons from DB and calls `schedulePick(season)` for each.
|
||||||
|
2. **`schedulePick(season)`**:
|
||||||
|
- Looks up current team + their `picksExpiresAt` from DB.
|
||||||
|
- If already set: `setTimeout(onExpiry, picksExpiresAt - now)`.
|
||||||
|
- If null (pick just started or timer not set): calls `initPick(season)` to write `picksExpiresAt = now + timeRemaining` and then schedules the timeout.
|
||||||
|
3. **`onExpiry(seasonId, teamId, pickNumber)`**: triggers the autopick (same logic as today's `triggerAutoPick`). After pick resolves, calls `schedulePick` for the next team.
|
||||||
|
4. **Recovery interval**: a `setInterval(recovery, 30_000)` (not 1 s!) queries active drafts and re-calls `schedulePick` for any whose in-memory timeout was lost (e.g., process restart). This is the only periodic DB query.
|
||||||
|
|
||||||
|
**Overnight pause handling**: when an overnight pause window activates, cancel the in-memory `setTimeout`, set `picksExpiresAt = null` in DB, record remaining time in `timeRemaining`. When the window ends, restore via `initPick`. The recovery interval detects this automatically on restart.
|
||||||
|
|
||||||
|
**Chess clock mode**: `timeRemaining` stores the bank; `picksExpiresAt = now + timeRemaining` when a pick starts. `picksStartedAt` tracks when the pick opened. On pick completion, calculate actual elapsed: `bankUsed = now - picksStartedAt`, new `timeRemaining = timeRemaining - bankUsed`.
|
||||||
|
|
||||||
|
### 1c. Socket.IO event change
|
||||||
|
|
||||||
|
Stop emitting `timer-update` every second. Instead emit:
|
||||||
|
|
||||||
|
| Event | When | Payload |
|
||||||
|
|---|---|---|
|
||||||
|
| `timer-pick-started` | Pick slot opens | `{ seasonId, teamId, pickNumber, expiresAt, timeRemaining }` |
|
||||||
|
| `timer-paused` | Draft paused or overnight pause | `{ seasonId, teamId, overnightPauseActive, resumesAtUTC? }` |
|
||||||
|
| `timer-resumed` | Pause lifted | `{ seasonId, teamId, expiresAt, timeRemaining }` |
|
||||||
|
| `pick-made` | Already exists | no change |
|
||||||
|
|
||||||
|
Client (`app/hooks/useDraftSocketEvents.ts`) counts down locally from `expiresAt` using a client-side `setInterval`. Remove the server-driven timer display logic.
|
||||||
|
|
||||||
|
### 1d. Client update
|
||||||
|
|
||||||
|
- On `timer-pick-started`: store `expiresAt` in state, start a local `setInterval` to display countdown.
|
||||||
|
- On `draft-state-sync` (reconnect): server sends current `picksExpiresAt` so client can resume countdown.
|
||||||
|
- Remove any logic that relied on receiving `timer-update` every second.
|
||||||
|
|
||||||
|
### Files changed in Phase 1
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `database/schema.ts` | Add `picksExpiresAt`, `picksStartedAt` to `draftTimers` |
|
||||||
|
| `server/timer.ts` | Rewrite: event-driven scheduler, 30s recovery interval |
|
||||||
|
| `server/socket.d.ts` | Add `timer-pick-started`, `timer-paused`, `timer-resumed`; remove `timer-update` |
|
||||||
|
| `server/socket.ts` | Emit new events on draft state sync (reconnect path) |
|
||||||
|
| `app/hooks/useDraftSocketEvents.ts` | Handle new events, remove `timer-update` handler |
|
||||||
|
| `app/routes/leagues/$leagueId.draft.$seasonId.tsx` | Client-side countdown from `expiresAt` |
|
||||||
|
|
||||||
|
**Verification**:
|
||||||
|
- `npm run typecheck` — no errors
|
||||||
|
- `npm run test:run` — passes
|
||||||
|
- Manual: start a draft, watch picks countdown without seeing any "timer-update" Socket.IO events in browser devtools. Confirm pick fires at correct time. Confirm reconnect restores correct remaining time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Separate Worker Process
|
||||||
|
|
||||||
|
**Goal**: timer runs in a process that is not restarted during web server deploys.
|
||||||
|
|
||||||
|
### 2a. Add Redis to infrastructure
|
||||||
|
|
||||||
|
Add to `docker-compose.yml`:
|
||||||
|
```yaml
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
```
|
||||||
|
|
||||||
|
Add env var `REDIS_URL` (default `redis://localhost:6379`).
|
||||||
|
|
||||||
|
Install: `npm install ioredis @socket.io/redis-adapter`
|
||||||
|
|
||||||
|
### 2b. Socket.IO Redis adapter on web servers
|
||||||
|
|
||||||
|
In `server/socket.ts`, after `new Server(...)`:
|
||||||
|
```ts
|
||||||
|
import { createAdapter } from "@socket.io/redis-adapter";
|
||||||
|
import { createClient } from "ioredis";
|
||||||
|
|
||||||
|
const pub = createClient(process.env.REDIS_URL);
|
||||||
|
const sub = pub.duplicate();
|
||||||
|
io.adapter(createAdapter(pub, sub));
|
||||||
|
```
|
||||||
|
|
||||||
|
This means any server instance can broadcast to any client in any room.
|
||||||
|
|
||||||
|
Remove the `connectedTeams` in-memory Map — replace with Redis-backed presence or accept best-effort on reconnect.
|
||||||
|
|
||||||
|
### 2c. New worker entry point
|
||||||
|
|
||||||
|
Create `worker/index.ts`:
|
||||||
|
```ts
|
||||||
|
import { startDraftTimerSystem } from "../server/timer";
|
||||||
|
import { startSnapshotSystem } from "../server/snapshots";
|
||||||
|
|
||||||
|
startDraftTimerSystem();
|
||||||
|
startSnapshotSystem();
|
||||||
|
```
|
||||||
|
|
||||||
|
**For Socket.IO emission from the worker**: use direct Redis pub/sub (simpler than a second Socket.IO server). Add a `server/timer-events.ts` module with `publishTimerEvent(channel, payload)` (worker side) and `subscribeTimerEvents(io)` (web server side).
|
||||||
|
|
||||||
|
### 2d. Docker Compose
|
||||||
|
|
||||||
|
Add worker service:
|
||||||
|
```yaml
|
||||||
|
worker:
|
||||||
|
image: ${REGISTRY}/brackt:${TAG}
|
||||||
|
command: node dist/worker.js
|
||||||
|
depends_on: [db, redis]
|
||||||
|
environment: *app-env
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
|
||||||
|
### Files to change in Phase 2
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `docker-compose.yml` | Add `redis` and `worker` services |
|
||||||
|
| `server/socket.ts` | Add Redis adapter; add timer-event subscription |
|
||||||
|
| `server/timer.ts` | Emit via Redis pub/sub instead of `getSocketIO()` |
|
||||||
|
| `server/snapshots.ts` | Move to worker only (remove import from `server.ts`) |
|
||||||
|
| `worker/index.ts` | New file: starts timer + snapshots |
|
||||||
|
| `Dockerfile` | Copy `worker/` dir; add `dist/worker.js` output |
|
||||||
|
| `package.json` | Add `build:worker` script |
|
||||||
|
| `.forgejo/workflows/deploy.yml` | Deploy worker service alongside app |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Zero-Downtime Web Server Deploy
|
||||||
|
|
||||||
|
**Goal**: rolling restart of web servers doesn't disconnect clients abruptly.
|
||||||
|
|
||||||
|
### 3a. Graceful shutdown
|
||||||
|
|
||||||
|
In `server.ts`, add SIGTERM handler:
|
||||||
|
```ts
|
||||||
|
process.on("SIGTERM", () => {
|
||||||
|
httpServer.close(() => process.exit(0));
|
||||||
|
io.close(() => process.exit(0));
|
||||||
|
setTimeout(() => process.exit(0), 10_000);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3b. Health check endpoint
|
||||||
|
|
||||||
|
Add `GET /health` → `{ status: "ok" }` to `server/app.ts`.
|
||||||
|
|
||||||
|
### 3c. Docker config
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
app:
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
|
||||||
|
interval: 10s
|
||||||
|
start_period: 15s
|
||||||
|
stop_grace_period: 15s
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Cron Infrastructure in Worker
|
||||||
|
|
||||||
|
**Goal**: standings sync and other periodic tasks run on a schedule, once.
|
||||||
|
|
||||||
|
Install `node-cron` in the worker and schedule jobs:
|
||||||
|
```ts
|
||||||
|
cron.schedule("0 3 * * *", () => syncAllStandings(db));
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a Postgres advisory lock if multiple workers are ever needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1 (event-driven timer) — no infrastructure needed, pure code change
|
||||||
|
↓
|
||||||
|
Phase 2 (worker + Redis) — adds Redis, separates worker process
|
||||||
|
↓
|
||||||
|
Phase 3 (graceful shutdown + health check) — deploy strategy improvement
|
||||||
|
↓
|
||||||
|
Phase 4 (cron in worker) — scheduling infrastructure
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Existing Code to Preserve/Reuse
|
||||||
|
|
||||||
|
| Code | Path | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `startDraftTimerSystem()` / `stopDraftTimerSystem()` | `server/timer.ts` | Keep the public API; replace internals |
|
||||||
|
| `triggerAutoPick()` | `server/timer.ts` | Unchanged logic, just called from `setTimeout` instead of `setInterval` |
|
||||||
|
| `checkOvernightPause()` | `server/timer.ts` | Keep as-is |
|
||||||
|
| `executeAutoPick()` | `app/models/draft-utils.ts` | Unchanged |
|
||||||
|
| `getSocketIO()` | `server/socket.ts` | Used in web server; worker will bypass via Redis pub/sub in Phase 2 |
|
||||||
|
| `draftSlotsCache` | `server/timer.ts` | Keep — still valid optimization for the recovery check |
|
||||||
|
|
@ -328,15 +328,15 @@ describe('Timer Autodraft Integration', () => {
|
||||||
expect(0 + 30).toBe(30);
|
expect(0 + 30).toBe(30);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should emit timer-update after adding increment', async () => {
|
it('should emit timer-pick-started after scheduling next pick', async () => {
|
||||||
const seasonId = 'season-123';
|
const seasonId = 'season-123';
|
||||||
const teamId = 'team-456';
|
const teamId = 'team-456';
|
||||||
mockSocketIO.to(`draft-${seasonId}`).emit('timer-update', {
|
mockSocketIO.to(`draft-${seasonId}`).emit('timer-pick-started', {
|
||||||
seasonId, teamId, timeRemaining: 30, currentPickNumber: 1,
|
seasonId, teamId, pickNumber: 1, expiresAt: Date.now() + 30000, timeRemaining: 30,
|
||||||
});
|
|
||||||
expect(mockSocketIO.emit).toHaveBeenCalledWith('timer-update', {
|
|
||||||
seasonId, teamId, timeRemaining: 30, currentPickNumber: 1,
|
|
||||||
});
|
});
|
||||||
|
expect(mockSocketIO.emit).toHaveBeenCalledWith('timer-pick-started',
|
||||||
|
expect.objectContaining({ seasonId, teamId, timeRemaining: 30 })
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
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 type { Server as HTTPServer } from "http";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq, and, asc } from "drizzle-orm";
|
import { eq, and, asc } from "drizzle-orm";
|
||||||
|
import { checkOvernightPause } from "./overnight-pause-check";
|
||||||
|
import { calculatePickInfo } from "~/models/draft-utils";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
|
|
||||||
|
|
@ -18,11 +20,18 @@ interface ServerToClientEvents {
|
||||||
"draft-started": (data: { seasonId: string; currentPickNumber: number }) => void;
|
"draft-started": (data: { seasonId: string; currentPickNumber: number }) => void;
|
||||||
"draft-completed": () => void;
|
"draft-completed": () => void;
|
||||||
"draft-room-closed": () => void;
|
"draft-room-closed": () => void;
|
||||||
"timer-update": (data: {
|
"timer-bank-updated": (data: { teamId: string; timeRemaining: number }) => void;
|
||||||
|
"timer-pick-started": (data: {
|
||||||
seasonId: string;
|
seasonId: string;
|
||||||
teamId: string;
|
teamId: string;
|
||||||
timeRemaining: number;
|
pickNumber: number;
|
||||||
currentPickNumber: number;
|
expiresAt: number; // ms timestamp
|
||||||
|
timeRemaining: number; // seconds remaining at emit time
|
||||||
|
}) => void;
|
||||||
|
"timer-overnight-paused": (data: {
|
||||||
|
seasonId: string;
|
||||||
|
teamId: string;
|
||||||
|
resumesAtUTC?: number;
|
||||||
}) => void;
|
}) => void;
|
||||||
"autodraft-updated": (data: {
|
"autodraft-updated": (data: {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
|
|
@ -47,6 +56,8 @@ interface ServerToClientEvents {
|
||||||
currentPickNumber: number;
|
currentPickNumber: number;
|
||||||
isPaused: boolean;
|
isPaused: boolean;
|
||||||
status: string;
|
status: string;
|
||||||
|
isOvernightPause: boolean;
|
||||||
|
overnightResumesAt?: number;
|
||||||
picks: Array<{
|
picks: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
pickNumber: number;
|
pickNumber: number;
|
||||||
|
|
@ -60,6 +71,7 @@ interface ServerToClientEvents {
|
||||||
timers: Array<{
|
timers: Array<{
|
||||||
teamId: string;
|
teamId: string;
|
||||||
timeRemaining: number;
|
timeRemaining: number;
|
||||||
|
expiresAt?: number; // ms timestamp; present for the currently-active team
|
||||||
}>;
|
}>;
|
||||||
queue?: Array<{
|
queue?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -224,7 +236,7 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
|
||||||
// tokens or flaky mobile networks. This socket-based sync provides an
|
// tokens or flaky mobile networks. This socket-based sync provides an
|
||||||
// additional, more reliable path since the socket is already connected.
|
// additional, more reliable path since the socket is already connected.
|
||||||
try {
|
try {
|
||||||
const [seasonData, picks, timerRows, queueItems, watchlistItems] = await Promise.all([
|
const [seasonData, picks, timerRows, draftSlots, queueItems, watchlistItems] = await Promise.all([
|
||||||
db.query.seasons.findFirst({
|
db.query.seasons.findFirst({
|
||||||
where: eq(schema.seasons.id, seasonId),
|
where: eq(schema.seasons.id, seasonId),
|
||||||
}),
|
}),
|
||||||
|
|
@ -258,6 +270,10 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
|
||||||
db.query.draftTimers.findMany({
|
db.query.draftTimers.findMany({
|
||||||
where: eq(schema.draftTimers.seasonId, seasonId),
|
where: eq(schema.draftTimers.seasonId, seasonId),
|
||||||
}),
|
}),
|
||||||
|
db.query.draftSlots.findMany({
|
||||||
|
where: eq(schema.draftSlots.seasonId, seasonId),
|
||||||
|
orderBy: asc(schema.draftSlots.draftOrder),
|
||||||
|
}),
|
||||||
teamId
|
teamId
|
||||||
? db.query.draftQueue.findMany({
|
? db.query.draftQueue.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
|
|
@ -278,15 +294,47 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (seasonData) {
|
if (seasonData) {
|
||||||
|
// Compute overnight-pause state so reconnecting clients see it immediately.
|
||||||
|
let isOvernightPause = false;
|
||||||
|
let overnightResumesAt: number | undefined;
|
||||||
|
|
||||||
|
if (
|
||||||
|
draftSlots.length > 0 &&
|
||||||
|
seasonData.status === "draft" &&
|
||||||
|
!seasonData.draftPaused
|
||||||
|
) {
|
||||||
|
const { pickInRound } = calculatePickInfo(
|
||||||
|
seasonData.currentPickNumber ?? 1,
|
||||||
|
draftSlots.length
|
||||||
|
);
|
||||||
|
const currentSlot = draftSlots.find((s) => s.draftOrder === pickInRound);
|
||||||
|
if (currentSlot) {
|
||||||
|
const result = await checkOvernightPause(seasonData, currentSlot.teamId);
|
||||||
|
isOvernightPause = result.active;
|
||||||
|
overnightResumesAt = result.resumesAtUTC;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
socket.emit("draft-state-sync", {
|
socket.emit("draft-state-sync", {
|
||||||
currentPickNumber: seasonData.currentPickNumber || 1,
|
currentPickNumber: seasonData.currentPickNumber || 1,
|
||||||
isPaused: seasonData.draftPaused || false,
|
isPaused: seasonData.draftPaused || false,
|
||||||
status: seasonData.status,
|
status: seasonData.status,
|
||||||
|
isOvernightPause,
|
||||||
|
overnightResumesAt,
|
||||||
picks,
|
picks,
|
||||||
timers: timerRows.map((t) => ({
|
timers: timerRows.map((t) => {
|
||||||
teamId: t.teamId,
|
const nowMs = Date.now();
|
||||||
timeRemaining: t.timeRemaining,
|
const expiresAt = t.picksExpiresAt?.getTime();
|
||||||
})),
|
const computedRemaining =
|
||||||
|
expiresAt && expiresAt > nowMs
|
||||||
|
? Math.max(0, Math.floor((expiresAt - nowMs) / 1000))
|
||||||
|
: t.timeRemaining;
|
||||||
|
return {
|
||||||
|
teamId: t.teamId,
|
||||||
|
timeRemaining: computedRemaining,
|
||||||
|
expiresAt: expiresAt && expiresAt > nowMs ? expiresAt : undefined,
|
||||||
|
};
|
||||||
|
}),
|
||||||
queue: teamId ? queueItems : undefined,
|
queue: teamId ? queueItems : undefined,
|
||||||
watchlistParticipantIds: teamId ? watchlistItems.map((w) => w.participantId) : undefined,
|
watchlistParticipantIds: teamId ? watchlistItems.map((w) => w.participantId) : undefined,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
669
server/timer.ts
669
server/timer.ts
|
|
@ -1,113 +1,33 @@
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq, and, asc, sql, lte, isNotNull } from "drizzle-orm";
|
import { eq, and, asc, lte, isNotNull } from "drizzle-orm";
|
||||||
import type { InferSelectModel } from "drizzle-orm";
|
import type { InferSelectModel } from "drizzle-orm";
|
||||||
import { getSocketIO } from "./socket";
|
import { getSocketIO } from "./socket";
|
||||||
import { executeAutoPick, calculatePickInfo } from "~/models/draft-utils";
|
import { executeAutoPick, calculatePickInfo } from "~/models/draft-utils";
|
||||||
import { isInOvernightWindow, getOvernightResumeUTC } from "~/lib/overnight-pause";
|
import { checkOvernightPause, evictOvernightPauseCache, overnightPauseCacheKeys, clearAllOvernightPauseCaches } from "./overnight-pause-check";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { startDraft } from "~/services/draft-autostart";
|
import { startDraft } from "~/services/draft-autostart";
|
||||||
|
|
||||||
let timerInterval: NodeJS.Timeout | null = null;
|
// Per-season in-memory state
|
||||||
let timerTickRunning = false;
|
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
|
// Draft slots never change during an active draft — cache them to avoid a redundant query.
|
||||||
// a redundant query on every tick.
|
|
||||||
const draftSlotsCache = new Map<string, { teamId: string; draftOrder: number }[]>();
|
const draftSlotsCache = new Map<string, { teamId: string; draftOrder: number }[]>();
|
||||||
|
|
||||||
// Team timezone cache for per_user overnight pause mode (seasonId → teamId → timezone).
|
let recoveryInterval: NodeJS.Timeout | null = null;
|
||||||
// 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> {
|
async function getDraftSlotsCached(seasonId: string): Promise<{ teamId: string; draftOrder: number }[]> {
|
||||||
let seasonMap = teamTimezoneCache.get(seasonId);
|
let slots = draftSlotsCache.get(seasonId);
|
||||||
if (!seasonMap) {
|
if (!slots) {
|
||||||
try {
|
slots = await db.query.draftSlots.findMany({
|
||||||
const rows = await db
|
where: eq(schema.draftSlots.seasonId, seasonId),
|
||||||
.select({ teamId: schema.teams.id, timezone: schema.users.timezone })
|
orderBy: asc(schema.draftSlots.draftOrder),
|
||||||
.from(schema.teams)
|
});
|
||||||
.leftJoin(schema.users, sql`${schema.teams.ownerId}::uuid = ${schema.users.id}`)
|
draftSlotsCache.set(seasonId, slots);
|
||||||
.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");
|
|
||||||
}
|
}
|
||||||
|
return slots;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkAndAutoStartDrafts(): Promise<void> {
|
async function checkAndAutoStartDrafts(): Promise<void> {
|
||||||
|
|
@ -136,8 +56,6 @@ async function checkAndAutoStartDrafts(): Promise<void> {
|
||||||
logger.error(`[Timer] Auto-start failed for season ${season.id}: ${result.error}`);
|
logger.error(`[Timer] Auto-start failed for season ${season.id}: ${result.error}`);
|
||||||
|
|
||||||
if (result.error === "No draft slots found for this season") {
|
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
|
await db
|
||||||
.update(schema.seasons)
|
.update(schema.seasons)
|
||||||
.set({ autoStartDraft: false })
|
.set({ autoStartDraft: false })
|
||||||
|
|
@ -147,206 +65,32 @@ async function checkAndAutoStartDrafts(): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function cancelPickTimeout(seasonId: string): void {
|
||||||
* Update all active draft timers
|
const existing = pickTimeouts.get(seasonId);
|
||||||
* Called every second by the timer interval
|
if (existing) {
|
||||||
*/
|
clearTimeout(existing);
|
||||||
async function updateDraftTimers(): Promise<void> {
|
pickTimeouts.delete(seasonId);
|
||||||
await checkAndAutoStartDrafts();
|
}
|
||||||
|
}
|
||||||
const io = getSocketIO();
|
|
||||||
|
function cancelOvernightResumeTimeout(seasonId: string): void {
|
||||||
// Get all active drafts
|
const existing = overnightResumeTimeouts.get(seasonId);
|
||||||
const activeDrafts = await db.query.seasons.findMany({
|
if (existing) {
|
||||||
where: eq(schema.seasons.status, "draft"),
|
clearTimeout(existing);
|
||||||
});
|
overnightResumeTimeouts.delete(seasonId);
|
||||||
|
}
|
||||||
if (activeDrafts.length === 0) {
|
}
|
||||||
draftSlotsCache.clear();
|
|
||||||
return;
|
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));
|
||||||
// Evict cache entries for seasons no longer actively drafting.
|
try {
|
||||||
const activeIds = new Set(activeDrafts.map((s) => s.id));
|
getSocketIO().to(`draft-${seasonId}`).emit("draft-paused", { seasonId, paused: true });
|
||||||
for (const cachedId of draftSlotsCache.keys()) {
|
} catch (err) {
|
||||||
if (!activeIds.has(cachedId)) draftSlotsCache.delete(cachedId);
|
logger.error("[Timer] Failed to emit draft-paused:", err);
|
||||||
}
|
|
||||||
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(
|
async function triggerAutoPick(
|
||||||
seasonId: string,
|
seasonId: string,
|
||||||
teamId: string,
|
teamId: string,
|
||||||
|
|
@ -364,10 +108,7 @@ async function triggerAutoPick(
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
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;
|
||||||
if (result.error === "Pick already made") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
logger.error(`[Timer] Auto-pick failed: ${result.error}`);
|
logger.error(`[Timer] Auto-pick failed: ${result.error}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -378,3 +119,331 @@ async function triggerAutoPick(
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 MAX_CONSECUTIVE_AUTOPICKS = 100; // safety cap against infinite loops on data bugs
|
||||||
|
|
||||||
|
for (let iteration = 0; iteration < MAX_CONSECUTIVE_AUTOPICKS; iteration++) {
|
||||||
|
const season = await db.query.seasons.findFirst({
|
||||||
|
where: eq(schema.seasons.id, seasonId),
|
||||||
|
});
|
||||||
|
if (!season || season.status !== "draft" || season.draftPaused) return;
|
||||||
|
|
||||||
|
const draftSlots = await getDraftSlotsCached(seasonId);
|
||||||
|
const totalTeams = draftSlots.length;
|
||||||
|
if (totalTeams === 0) return;
|
||||||
|
|
||||||
|
const currentPickNumber = season.currentPickNumber ?? 1;
|
||||||
|
const { pickInRound } = calculatePickInfo(currentPickNumber, totalTeams);
|
||||||
|
const currentDraftSlot = draftSlots.find((s) => s.draftOrder === pickInRound);
|
||||||
|
if (!currentDraftSlot) return;
|
||||||
|
|
||||||
|
const currentTeamId = currentDraftSlot.teamId;
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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({
|
||||||
|
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.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,
|
||||||
|
currentPickNumber,
|
||||||
|
shouldAutodraft ? (autodraftSettings ?? null) : null
|
||||||
|
);
|
||||||
|
if (!success) { await pauseDraftOnError(seasonId, currentTeamId); return; }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
// Union both cache key sets so overnight-pause entries populated by socket.ts
|
||||||
|
// (without a corresponding draftSlotsCache entry) are also evicted.
|
||||||
|
const activeIds = new Set(activeDrafts.map((s) => s.id));
|
||||||
|
const 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();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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