Fix draft timer: broadcasts, increments, reconnect sync, overnight pause (#72)
## Summary - **Timer bank broadcasts**: emit `timer-bank-updated` after every pick so all clients immediately see the updated bank instead of waiting for the next `timer-pick-started` - **Increment accuracy**: capture `pickMadeAt` at route entry (before auth/DB overhead) and use `Math.ceil` so credited seconds always match the client countdown display - **Race condition fix**: hold `schedulingInProgress` lock for the full timer callback to prevent the recovery interval from scheduling a duplicate timeout mid-pick - **force-autopick fix**: call `rescheduleTimer` so the next team's clock starts immediately instead of waiting for the old timeout to naturally expire - **adjust-time-bank fix**: for on-clock teams, shift `picksExpiresAt` by the adjustment and reschedule so the client countdown updates; block adjustments that would reduce the bank to zero - **New socket events**: `timer-pick-started`, `timer-overnight-paused`, `timer-bank-updated` with full type definitions; removed dead `timer-update` event - **Reconnect sync**: `draft-state-sync` now includes `expiresAt` for the active timer and `isOvernightPause` state so reconnecting clients see accurate countdown and pause banner immediately without a page reload - **Room closure countdown**: capture client-side timestamp when draft completes so the "Room closes in X" countdown actually ticks down before the loader revalidates with `draftCompletedAt` - **Countdown interval**: run at 500ms with `Math.ceil` to prevent skipped seconds under event loop pressure - **Overnight pause UX**: `canPick` only blocks on commissioner pause — overnight pause freezes the timer but the on-clock player can still pick early - **Overnight pause refactor**: extract `checkOvernightPause` to `server/overnight-pause-check.ts`, breaking the `timer↔socket` circular import and sharing the timezone cache across both callers with correct eviction - **PostgreSQL type fix**: cast `varchar` owner ID to `uuid` in `getTeamTimezone` join ## Test plan - [ ] Manual pick: all clients see bank increment immediately after pick - [ ] Timeout pick: all clients see bank update (0 → increment); next clock starts within ~1s - [ ] Force-autopick: next team's clock starts immediately; no "Pick already made" log - [ ] Force-manual-pick: all clients see bank increment - [ ] Pause while clock running: countdown freezes on all clients - [ ] Resume: clock continues from frozen value - [ ] adjust-time-bank on on-clock team: countdown shifts immediately - [ ] adjust-time-bank to zero: returns 400 error - [ ] Reconnect (socket disconnect/connect): countdown resumes for correct team - [ ] Hard refresh mid-draft: on-clock indicator and countdown correct immediately - [ ] Draft complete: "Room closes in X" counts down - [ ] Overnight pause: banner shows, pick buttons still enabled, timer frozen - [ ] `npm run test:run` — all 158 files / 2351 tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: #72
This commit is contained in:
parent
f657293430
commit
f96c8f5244
27 changed files with 1961 additions and 1546 deletions
43
.production-info/docker-compose-production.yml
Normal file
43
.production-info/docker-compose-production.yml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
services:
|
||||
migrate:
|
||||
container_name: brackt-migrate
|
||||
image: sjc.vultrcr.com/chrisparsons/brackt:latest
|
||||
command: ["node", "/app/scripts/migrate.mjs"]
|
||||
networks:
|
||||
- internal
|
||||
restart: "no"
|
||||
|
||||
brackt:
|
||||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
max_attempts: 5
|
||||
container_name: brackt
|
||||
image: sjc.vultrcr.com/chrisparsons/brackt:latest
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PGBOUNCER=true
|
||||
- SQUIGGLE_CONTACT_EMAIL=chris@brackt.com
|
||||
- BETTER_AUTH_URL=https://brackt.com
|
||||
- APP_URL=https://brackt.com
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.brackt.rule=Host(`brackt.com`)
|
||||
- traefik.http.routers.brackt.tls=true
|
||||
- traefik.http.routers.brackt.tls.certresolver=lets-encrypt
|
||||
- traefik.http.routers.brackt.entrypoints=websecure
|
||||
- traefik.http.services.brackt.loadbalancer.server.port=3000
|
||||
- traefik.http.routers.brackt-www.rule=Host(`www.brackt.com`)
|
||||
- traefik.http.routers.brackt-www.tls=true
|
||||
- traefik.http.routers.brackt-www.tls.certresolver=lets-encrypt
|
||||
- traefik.http.routers.brackt-www.entrypoints=websecure
|
||||
- traefik.http.routers.brackt-www.middlewares=redirect-to-nonwww
|
||||
- traefik.http.middlewares.redirect-to-nonwww.redirectregex.regex=^https://www\.(.*)
|
||||
- traefik.http.middlewares.redirect-to-nonwww.redirectregex.replacement=https://$${1}
|
||||
- traefik.http.middlewares.redirect-to-nonwww.redirectregex.permanent=true
|
||||
networks:
|
||||
- internal
|
||||
- web
|
||||
|
|
@ -35,6 +35,7 @@ interface UseDraftSocketEventsParams {
|
|||
setConnectedTeams: (fn: (prev: Set<string>) => Set<string>) => void;
|
||||
setQueue: (fn: (prev: QueueItem[]) => QueueItem[]) => void;
|
||||
setTeamTimers: (fn: (prev: Record<string, number>) => Record<string, number>) => void;
|
||||
setPickTimerExpiresAt: (state: { teamId: string; expiresAt: number } | null) => void;
|
||||
setIsOvernightPause: (value: boolean) => void;
|
||||
setOvernightResumesAt: (value: Date | null) => void;
|
||||
setWatchedParticipantIds: (value: Set<string>) => void;
|
||||
|
|
@ -65,6 +66,7 @@ export function useDraftSocketEvents({
|
|||
setConnectedTeams,
|
||||
setQueue,
|
||||
setTeamTimers,
|
||||
setPickTimerExpiresAt,
|
||||
setIsOvernightPause,
|
||||
setOvernightResumesAt,
|
||||
setWatchedParticipantIds,
|
||||
|
|
@ -80,6 +82,8 @@ export function useDraftSocketEvents({
|
|||
isDraftComplete?: boolean;
|
||||
};
|
||||
const handlePickMade = (data: PickMadePayload) => {
|
||||
// Stop any running countdown — timer-pick-started for the next team will restart it.
|
||||
setPickTimerExpiresAt(null);
|
||||
if (isRevalidatingRef.current) {
|
||||
pendingPicksDuringRevalidationRef.current.push(data.pick);
|
||||
} else {
|
||||
|
|
@ -108,26 +112,38 @@ export function useDraftSocketEvents({
|
|||
}
|
||||
};
|
||||
|
||||
const handleTimerUpdate = (data: {
|
||||
const handleTimerPickStarted = (data: {
|
||||
seasonId: string;
|
||||
teamId: string;
|
||||
pickNumber: number;
|
||||
expiresAt: number;
|
||||
timeRemaining: number;
|
||||
currentPickNumber: number | null;
|
||||
overnightPauseActive?: boolean;
|
||||
resumesAtUTC?: number;
|
||||
}) => {
|
||||
setTeamTimers((prev) => {
|
||||
if (prev[data.teamId] === data.timeRemaining) return prev;
|
||||
return { ...prev, [data.teamId]: data.timeRemaining };
|
||||
});
|
||||
if (data.currentPickNumber !== null) {
|
||||
setCurrentPick(data.currentPickNumber);
|
||||
}
|
||||
const pauseActive = data.overnightPauseActive ?? false;
|
||||
setIsOvernightPause(pauseActive);
|
||||
setOvernightResumesAt(pauseActive && data.resumesAtUTC ? new Date(data.resumesAtUTC) : null);
|
||||
setCurrentPick(data.pickNumber);
|
||||
setTeamTimers((prev) => ({ ...prev, [data.teamId]: data.timeRemaining }));
|
||||
setPickTimerExpiresAt({ teamId: data.teamId, expiresAt: data.expiresAt });
|
||||
setIsOvernightPause(false);
|
||||
setOvernightResumesAt(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 handleDraftCompleted = () => setIsDraftComplete(true);
|
||||
|
||||
|
|
@ -202,7 +218,8 @@ export function useDraftSocketEvents({
|
|||
setPicks((prev) => prev.filter((p) => p.pickNumber < data.pickNumber));
|
||||
setCurrentPick(data.pickNumber);
|
||||
setIsDraftComplete(false);
|
||||
setIsPaused(false);
|
||||
setIsPaused(true);
|
||||
setPickTimerExpiresAt(null);
|
||||
};
|
||||
|
||||
const handleDraftStateSync = (data: {
|
||||
|
|
@ -210,7 +227,9 @@ export function useDraftSocketEvents({
|
|||
currentPickNumber: number;
|
||||
isPaused: boolean;
|
||||
status: string;
|
||||
timers?: Array<{ teamId: string; timeRemaining: number }>;
|
||||
isOvernightPause?: boolean;
|
||||
overnightResumesAt?: number;
|
||||
timers?: Array<{ teamId: string; timeRemaining: number; expiresAt?: number }>;
|
||||
queue?: QueueItem[];
|
||||
watchlistParticipantIds?: string[];
|
||||
}) => {
|
||||
|
|
@ -225,6 +244,24 @@ export function useDraftSocketEvents({
|
|||
data.timers?.forEach((t) => { updated[t.teamId] = t.timeRemaining; });
|
||||
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) {
|
||||
const q = data.queue;
|
||||
|
|
@ -258,7 +295,9 @@ export function useDraftSocketEvents({
|
|||
};
|
||||
|
||||
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-resumed", handleDraftResumed as (data: unknown) => void);
|
||||
on("draft-completed", handleDraftCompleted as (data: unknown) => void);
|
||||
|
|
@ -278,7 +317,9 @@ export function useDraftSocketEvents({
|
|||
|
||||
return () => {
|
||||
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-resumed", handleDraftResumed as (data: unknown) => void);
|
||||
off("draft-completed", handleDraftCompleted as (data: unknown) => void);
|
||||
|
|
|
|||
|
|
@ -187,12 +187,12 @@ describe("executeAutoPick — timer mode behavior", () => {
|
|||
// ── chess_clock mode ────────────────────────────────────────────────────────
|
||||
|
||||
describe("chess_clock mode", () => {
|
||||
it("emits timer-update with bank + increment after a timer-triggered pick", async () => {
|
||||
it("writes exactly increment to DB timer when timer expired (timeRemainingAtPick = 0)", async () => {
|
||||
// Default mock has timeRemaining: 0 and picksExpiresAt: null (expired timer)
|
||||
const mockDb = makeMockDb({ draftTimerMode: "chess_clock", draftIncrementTime: 15 });
|
||||
// Timer expired at 0; 0 + 15 = 15 after the increment
|
||||
mockDb.returning
|
||||
.mockResolvedValueOnce([mockDraftPick]) // insert draft pick
|
||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 15 }]); // update timer
|
||||
.mockResolvedValueOnce([mockDraftPick])
|
||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 15 }]);
|
||||
|
||||
await executeAutoPick({
|
||||
seasonId: SEASON_ID,
|
||||
|
|
@ -203,9 +203,9 @@ describe("executeAutoPick — timer mode behavior", () => {
|
|||
db: mockDb,
|
||||
});
|
||||
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||
"timer-update",
|
||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 15 })
|
||||
// 0 remaining + 15 increment = 15; picksExpiresAt cleared so next turn starts fresh
|
||||
expect(mockDb.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeRemaining: 15, picksExpiresAt: null, picksStartedAt: null })
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -245,9 +245,9 @@ describe("executeAutoPick — timer mode behavior", () => {
|
|||
db: mockDb,
|
||||
});
|
||||
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||
"timer-update",
|
||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
||||
// 0 remaining (expired) + 30 increment = 30
|
||||
expect(mockDb.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -273,7 +273,7 @@ describe("executeAutoPick — timer mode behavior", () => {
|
|||
// ── standard mode ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("standard mode", () => {
|
||||
it("emits timer-update with exactly draftIncrementTime after a timer-triggered pick", async () => {
|
||||
it("resets timer to exactly draftIncrementTime in DB after a timer-triggered pick", async () => {
|
||||
const mockDb = makeMockDb({ draftTimerMode: "standard", draftIncrementTime: 30 });
|
||||
mockDb.returning
|
||||
.mockResolvedValueOnce([mockDraftPick])
|
||||
|
|
@ -288,9 +288,8 @@ describe("executeAutoPick — timer mode behavior", () => {
|
|||
db: mockDb,
|
||||
});
|
||||
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||
"timer-update",
|
||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
||||
expect(mockDb.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -327,9 +326,8 @@ describe("executeAutoPick — timer mode behavior", () => {
|
|||
db: mockDb,
|
||||
});
|
||||
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||
"timer-update",
|
||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 90 })
|
||||
expect(mockDb.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeRemaining: 90, picksExpiresAt: null, picksStartedAt: null })
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, and, notInArray, desc, inArray, sql, asc } from "drizzle-orm";
|
||||
import { eq, and, notInArray, desc, inArray, asc } from "drizzle-orm";
|
||||
import { logger } from "~/lib/logger";
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import { getTeamQueue, getAllQueuesForSeason } from "./draft-queue";
|
||||
|
|
@ -471,6 +471,7 @@ export async function executeAutoPick(params: {
|
|||
autodraftSettings?: AutodraftSettings | null;
|
||||
db?: ReturnType<typeof database>;
|
||||
chainEnabled?: boolean; // Set to false when called from within the autodraft chain to prevent recursion
|
||||
pickMadeAt?: number; // ms timestamp; pass from route handler for accurate remaining-time credit
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
|
|
@ -491,9 +492,13 @@ export async function executeAutoPick(params: {
|
|||
commissionerUserId,
|
||||
autodraftSettings,
|
||||
db: providedDb,
|
||||
pickMadeAt: callerPickMadeAt,
|
||||
} = params;
|
||||
|
||||
const db = providedDb || database();
|
||||
// Use caller-provided timestamp when available (route handler captures it before auth/DB overhead).
|
||||
// Fall back to now for timer-triggered paths where the callback fires at the expiry instant.
|
||||
const pickMadeAt = callerPickMadeAt ?? Date.now();
|
||||
|
||||
try {
|
||||
// Race condition protection - check if pick already made
|
||||
|
|
@ -524,6 +529,10 @@ export async function executeAutoPick(params: {
|
|||
};
|
||||
}
|
||||
|
||||
if (season.draftPaused) {
|
||||
return { success: false, error: "Draft is paused" };
|
||||
}
|
||||
|
||||
// Get draft slots to calculate round/pickInRound and get all team IDs
|
||||
const draftSlots = await db.query.draftSlots.findMany({
|
||||
where: eq(schema.draftSlots.seasonId, seasonId),
|
||||
|
|
@ -611,7 +620,8 @@ export async function executeAutoPick(params: {
|
|||
? (commissionerUserId || "")
|
||||
: "";
|
||||
|
||||
// Fetch current timer before pick so we have the time remaining at decision point
|
||||
// Fetch current timer before pick so we have the time remaining at decision point.
|
||||
// Use picksExpiresAt (wall-clock truth) rather than the stale timeRemaining column.
|
||||
const incrementTime = season.draftIncrementTime || 30;
|
||||
const currentTimer = await db.query.draftTimers.findFirst({
|
||||
where: and(
|
||||
|
|
@ -624,6 +634,17 @@ export async function executeAutoPick(params: {
|
|||
logger.warn(`[AutoPick] No timer found for team ${teamId} in season ${seasonId}`);
|
||||
}
|
||||
|
||||
// Three cases:
|
||||
// - picksExpiresAt set, in the future: pick was made early → credit ceiling of remaining seconds
|
||||
// (Math.ceil matches the client display, which also uses ceil)
|
||||
// - picksExpiresAt set, expired: timer fired → 0 (pickMadeAt >= picksExpiresAt for timer path)
|
||||
// - picksExpiresAt null: timer not running this turn (while_on, bank-depleted) → use timeRemaining
|
||||
const timeRemainingAtPick = (() => {
|
||||
if (!currentTimer?.picksExpiresAt) return currentTimer?.timeRemaining ?? 0;
|
||||
const msRemaining = currentTimer.picksExpiresAt.getTime() - pickMadeAt;
|
||||
return msRemaining > 0 ? Math.ceil(msRemaining / 1000) : 0;
|
||||
})();
|
||||
|
||||
// Create the draft pick — use ON CONFLICT DO NOTHING so that concurrent timer
|
||||
// ticks racing to the same pick slot are handled atomically at the DB level
|
||||
// rather than relying on the TOCTOU pre-check above.
|
||||
|
|
@ -638,8 +659,8 @@ export async function executeAutoPick(params: {
|
|||
pickInRound,
|
||||
pickedByUserId,
|
||||
pickedByType: "auto",
|
||||
// Records the team's bank balance at the moment the pick was made (seconds remaining)
|
||||
timeUsed: currentTimer ? currentTimer.timeRemaining : undefined,
|
||||
// Records the team's actual remaining time at the moment the pick was made
|
||||
timeUsed: currentTimer ? timeRemainingAtPick : undefined,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
|
@ -660,63 +681,32 @@ export async function executeAutoPick(params: {
|
|||
const isDraftComplete = nextPickNumber > totalPicks;
|
||||
|
||||
// Update the team's timer after the auto-pick.
|
||||
// Standard mode: reset to the per-pick time (atomic, prevents race with timer loop).
|
||||
// Chess clock mode: add the increment so the team starts their next turn with some time
|
||||
// (without this, a single timeout would permanently freeze their bank at 0).
|
||||
let emitTimeRemaining: number;
|
||||
// Standard mode: reset to the per-pick time.
|
||||
// Chess clock: credit actual remaining time + increment. When the timer fired, timeRemainingAtPick
|
||||
// is 0, so the team gets exactly incrementTime — their bank is not accidentally refilled.
|
||||
// Also clear picksExpiresAt/picksStartedAt so _schedulePickForSeason starts fresh next turn.
|
||||
const newTimeRemaining = season.draftTimerMode === "standard"
|
||||
? incrementTime
|
||||
: timeRemainingAtPick + incrementTime;
|
||||
|
||||
if (season.draftTimerMode === "standard") {
|
||||
const [updatedTimer] = await db
|
||||
.update(schema.draftTimers)
|
||||
.set({ timeRemaining: sql`${incrementTime}`, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(schema.draftTimers.seasonId, seasonId),
|
||||
eq(schema.draftTimers.teamId, teamId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
emitTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
|
||||
logger.log(
|
||||
`[AutoPick] Reset timer for team ${teamId} to ${emitTimeRemaining}s (standard mode)`
|
||||
);
|
||||
} else {
|
||||
// Chess clock: add the increment (atomic add, same as a manual pick).
|
||||
const [updatedTimer] = await db
|
||||
.update(schema.draftTimers)
|
||||
.set({
|
||||
timeRemaining: sql`${schema.draftTimers.timeRemaining} + ${incrementTime}`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(schema.draftTimers.seasonId, seasonId),
|
||||
eq(schema.draftTimers.teamId, teamId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
emitTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
|
||||
if (!updatedTimer) {
|
||||
await db.insert(schema.draftTimers).values({ seasonId, teamId, timeRemaining: emitTimeRemaining });
|
||||
}
|
||||
logger.log(
|
||||
`[AutoPick] Chess clock auto-pick for team ${teamId}, bank is now ${emitTimeRemaining}s (+${incrementTime}s increment)`
|
||||
);
|
||||
const [updatedTimer] = await db
|
||||
.update(schema.draftTimers)
|
||||
.set({
|
||||
timeRemaining: newTimeRemaining,
|
||||
picksExpiresAt: null as Date | null,
|
||||
picksStartedAt: null as Date | null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(schema.draftTimers.seasonId, seasonId), eq(schema.draftTimers.teamId, teamId)))
|
||||
.returning();
|
||||
|
||||
if (!updatedTimer) {
|
||||
await db.insert(schema.draftTimers).values({ seasonId, teamId, timeRemaining: newTimeRemaining });
|
||||
}
|
||||
|
||||
try {
|
||||
getSocketIO().to(`draft-${seasonId}`).emit("timer-update", {
|
||||
seasonId,
|
||||
teamId,
|
||||
timeRemaining: emitTimeRemaining,
|
||||
currentPickNumber: nextPickNumber,
|
||||
overnightPauseActive: false,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("[AutoPick] Socket.IO timer-update error:", error);
|
||||
}
|
||||
|
||||
// Next team's timer is unchanged — their bank carries forward as-is
|
||||
logger.log(
|
||||
`[AutoPick] Timer for team ${teamId}: ${newTimeRemaining}s (${season.draftTimerMode} mode, +${incrementTime}s increment)`
|
||||
);
|
||||
|
||||
// Update season's current pick number
|
||||
await db
|
||||
|
|
@ -808,6 +798,10 @@ export async function executeAutoPick(params: {
|
|||
isDraftComplete,
|
||||
});
|
||||
|
||||
// Emit timer bank update AFTER pick-made so handlePickMade stops the countdown
|
||||
// interval first — otherwise the interval could tick 0 and overwrite the new bank.
|
||||
io.to(`draft-${seasonId}`).emit("timer-bank-updated", { teamId, timeRemaining: newTimeRemaining });
|
||||
|
||||
// Emit draft-completed event if applicable
|
||||
if (isDraftComplete) {
|
||||
io.to(`draft-${seasonId}`).emit("draft-completed");
|
||||
|
|
@ -817,16 +811,15 @@ export async function executeAutoPick(params: {
|
|||
logger.error("[AutoPick] Socket.IO events error:", error);
|
||||
}
|
||||
|
||||
// Recompute Brackt EV/VORP after this pick is committed so autopick paths
|
||||
// (force-autopick, timer, user autodraft) are not one pick behind.
|
||||
try {
|
||||
const updates = await runBracktHarvilleForFantasySeason(seasonId, db);
|
||||
if (updates.length > 0) {
|
||||
getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[AutoPick] Error updating Brackt EVs after pick:", error);
|
||||
}
|
||||
// Recompute Brackt EV/VORP after this pick is committed. Fire-and-forget so it
|
||||
// doesn't delay schedulePickForSeason (and therefore timer-pick-started) for the next team.
|
||||
runBracktHarvilleForFantasySeason(seasonId, db)
|
||||
.then((updates) => {
|
||||
if (updates.length > 0) {
|
||||
getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates });
|
||||
}
|
||||
})
|
||||
.catch((error) => logger.error("[AutoPick] Error updating Brackt EVs after pick:", error));
|
||||
|
||||
// Announce before triggering the chain so Discord messages arrive in pick-number
|
||||
// order. If the chain ran first, chained picks would announce before this one.
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ vi.mock("~/models/user", () => ({
|
|||
vi.mock("~/models/audit-log", () => ({
|
||||
logCommissionerAction: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("~/server/timer", () => ({
|
||||
rescheduleTimer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -367,27 +370,18 @@ describe("draft.force-manual-pick action", () => {
|
|||
describe("chess_clock mode", () => {
|
||||
it("adds the increment to the picking team's bank", async () => {
|
||||
// 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 });
|
||||
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||
"timer-update",
|
||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
|
||||
expect(mockDb.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeRemaining: 105, picksExpiresAt: null, picksStartedAt: null })
|
||||
);
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
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 () => {
|
||||
mockDb.returning
|
||||
.mockResolvedValueOnce([mockDraftPick])
|
||||
.mockResolvedValueOnce([{ id: "timer-1", seasonId: SEASON_ID, teamId: TEAM_ID, timeRemaining: 30 }]);
|
||||
|
||||
await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||||
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||
"timer-update",
|
||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
||||
expect(mockDb.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
|
||||
);
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
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 });
|
||||
|
||||
const nextTeamTimerEmits = mockSocketIO.emit.mock.calls.filter(
|
||||
([event, payload]: [string, any]) =>
|
||||
event === "timer-update" && payload?.teamId === NEXT_TEAM_ID
|
||||
);
|
||||
expect(nextTeamTimerEmits).toHaveLength(0);
|
||||
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { RouterContextProvider } from "react-router";
|
||||
import { action } from "~/routes/api/draft.force-manual-pick";
|
||||
import { rescheduleTimer } from "~/server/timer";
|
||||
|
||||
const ctx = {} as unknown as RouterContextProvider;
|
||||
|
||||
|
|
@ -41,6 +42,9 @@ vi.mock("~/models/user", () => ({
|
|||
vi.mock("~/models/audit-log", () => ({
|
||||
logCommissionerAction: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("~/server/timer", () => ({
|
||||
rescheduleTimer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -192,46 +196,35 @@ describe("draft.force-manual-pick action — timer mode behavior", () => {
|
|||
});
|
||||
|
||||
describe("commissioner force pick", () => {
|
||||
it("emits timer-update with bank + increment", async () => {
|
||||
// Pre-pick bank: 75s. Expected after increment: 75 + 30 = 105s.
|
||||
mockDb.returning
|
||||
.mockResolvedValueOnce([mockDraftPick])
|
||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
||||
|
||||
it("stores bank + increment in DB timer and calls rescheduleTimer", async () => {
|
||||
// Pre-pick bank: 75s (no picksExpiresAt). 75 + 30 = 105.
|
||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||
"timer-update",
|
||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
|
||||
expect(mockDb.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeRemaining: 105, picksExpiresAt: null, picksStartedAt: null })
|
||||
);
|
||||
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
// Two DB updates: timer row + season.currentPickNumber
|
||||
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 () => {
|
||||
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 100 });
|
||||
// 100 + 30 = 130
|
||||
mockDb.returning
|
||||
.mockResolvedValueOnce([mockDraftPick])
|
||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 130 }]);
|
||||
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 100 });
|
||||
|
||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||
"timer-update",
|
||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 130 })
|
||||
expect(mockDb.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeRemaining: 130, picksExpiresAt: null, picksStartedAt: null })
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
it("emits timer-update with bank + increment", 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 }]);
|
||||
|
||||
it("stores bank + increment in DB timer and calls rescheduleTimer", async () => {
|
||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||
|
||||
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 () => {
|
||||
// Pre-pick bank doesn't matter — standard always resets
|
||||
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 });
|
||||
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||
"timer-update",
|
||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
||||
expect(mockDb.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
|
||||
);
|
||||
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||
});
|
||||
|
||||
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
|
||||
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 });
|
||||
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||
"timer-update",
|
||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
||||
expect(mockDb.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -327,32 +305,25 @@ describe("draft.force-manual-pick action — timer mode behavior", () => {
|
|||
});
|
||||
|
||||
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 });
|
||||
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||
"timer-update",
|
||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
||||
expect(mockDb.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
|
||||
);
|
||||
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||
});
|
||||
|
||||
it("uses custom draftIncrementTime when configured", async () => {
|
||||
mockDb.query.seasons.findFirst.mockResolvedValue(
|
||||
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 });
|
||||
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||
"timer-update",
|
||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 60 })
|
||||
expect(mockDb.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeRemaining: 60, picksExpiresAt: null, picksStartedAt: null })
|
||||
);
|
||||
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { RouterContextProvider } from "react-router";
|
||||
import { action } from "~/routes/api/draft.make-pick";
|
||||
import { rescheduleTimer } from "~/server/timer";
|
||||
|
||||
const ctx = {} as unknown as RouterContextProvider;
|
||||
|
||||
|
|
@ -39,6 +40,9 @@ vi.mock("~/models/draft-utils", () => ({
|
|||
vi.mock("~/models/user", () => ({
|
||||
isUserAdmin: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/server/timer", () => ({
|
||||
rescheduleTimer: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -188,44 +192,33 @@ describe("draft.make-pick action — timer mode behavior", () => {
|
|||
describe("owner pick", () => {
|
||||
// Auth default is OWNER_ID (team owner), no extra setup needed.
|
||||
|
||||
it("emits timer-update with bank + increment", async () => {
|
||||
// DB returns 75 + 30 = 105 after the atomic add
|
||||
mockDb.returning
|
||||
.mockResolvedValueOnce([mockDraftPick])
|
||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 105 }]);
|
||||
|
||||
it("stores bank + increment in DB timer and calls rescheduleTimer", async () => {
|
||||
// Pre-pick bank: 75s (no picksExpiresAt, falls back to timeRemaining). 75 + 30 = 105.
|
||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||
"timer-update",
|
||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 105 })
|
||||
expect(mockDb.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeRemaining: 105, picksExpiresAt: null, picksStartedAt: null })
|
||||
);
|
||||
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||
});
|
||||
|
||||
it("accumulates a larger bank when more time was remaining", async () => {
|
||||
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 100 });
|
||||
// 100 + 30 = 130
|
||||
mockDb.returning
|
||||
.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 }]);
|
||||
mockDb.query.draftTimers.findFirst.mockResolvedValue({ id: "timer-1", timeRemaining: 100 });
|
||||
|
||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||
|
||||
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 () => {
|
||||
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 }]);
|
||||
|
||||
it("stores bank + increment in DB timer and calls rescheduleTimer", async () => {
|
||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||
|
||||
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 () => {
|
||||
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 }]);
|
||||
|
||||
it("stores bank + increment in DB timer and calls rescheduleTimer", async () => {
|
||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||
|
||||
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 () => {
|
||||
mockDb.returning
|
||||
.mockResolvedValueOnce([mockDraftPick])
|
||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
|
||||
|
||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||
"timer-update",
|
||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
||||
expect(mockDb.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
|
||||
);
|
||||
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||
});
|
||||
|
||||
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
|
||||
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 });
|
||||
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||
"timer-update",
|
||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
||||
expect(mockDb.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -363,16 +332,12 @@ describe("draft.make-pick action — timer mode behavior", () => {
|
|||
mockDraftSlots[1],
|
||||
]);
|
||||
|
||||
mockDb.returning
|
||||
.mockResolvedValueOnce([mockDraftPick])
|
||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
|
||||
|
||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||
"timer-update",
|
||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
||||
expect(mockDb.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
|
||||
);
|
||||
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||
});
|
||||
|
||||
it("admin pick — resets bank to exactly draftIncrementTime", async () => {
|
||||
|
|
@ -385,32 +350,25 @@ describe("draft.make-pick action — timer mode behavior", () => {
|
|||
mockDraftSlots[1],
|
||||
]);
|
||||
|
||||
mockDb.returning
|
||||
.mockResolvedValueOnce([mockDraftPick])
|
||||
.mockResolvedValueOnce([{ id: "timer-1", teamId: TEAM_ID, timeRemaining: 30 }]);
|
||||
|
||||
await action({ request: makeRequest(), params: {}, context: ctx });
|
||||
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||
"timer-update",
|
||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 30 })
|
||||
expect(mockDb.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeRemaining: 30, picksExpiresAt: null, picksStartedAt: null })
|
||||
);
|
||||
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||
});
|
||||
|
||||
it("uses custom draftIncrementTime when configured", async () => {
|
||||
mockDb.query.seasons.findFirst.mockResolvedValue(
|
||||
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 });
|
||||
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith(
|
||||
"timer-update",
|
||||
expect.objectContaining({ teamId: TEAM_ID, timeRemaining: 90 })
|
||||
expect(mockDb.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ timeRemaining: 90, picksExpiresAt: null, picksStartedAt: null })
|
||||
);
|
||||
expect(vi.mocked(rescheduleTimer)).toHaveBeenCalledWith(SEASON_ID);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import * as schema from "~/database/schema";
|
|||
import { isCommissioner } from "~/models/commissioner";
|
||||
import { logCommissionerAction } from "~/models/audit-log";
|
||||
import { getSocketIO } from "../../../server/socket";
|
||||
import { rescheduleTimer } from "../../../server/timer";
|
||||
import { logger } from "~/lib/logger";
|
||||
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
|
|
@ -59,6 +60,7 @@ export async function action(args: ActionFunctionArgs) {
|
|||
);
|
||||
|
||||
let newTime: number;
|
||||
let isOnClock = false;
|
||||
|
||||
if (!currentTimer) {
|
||||
if (adjustment <= 0) {
|
||||
|
|
@ -71,11 +73,31 @@ export async function action(args: ActionFunctionArgs) {
|
|||
timeRemaining: newTime,
|
||||
});
|
||||
} else {
|
||||
newTime = Math.max(0, currentTimer.timeRemaining + adjustment);
|
||||
await db
|
||||
.update(schema.draftTimers)
|
||||
.set({ timeRemaining: newTime, updatedAt: new Date() })
|
||||
.where(eq(schema.draftTimers.id, currentTimer.id));
|
||||
const now = Date.now();
|
||||
if (currentTimer.picksExpiresAt && currentTimer.picksExpiresAt.getTime() > now) {
|
||||
// Team is on the clock — base the adjustment on the live expiry, not the stale timeRemaining.
|
||||
isOnClock = true;
|
||||
const msRemaining = currentTimer.picksExpiresAt.getTime() - now;
|
||||
const currentRemaining = Math.max(0, Math.ceil(msRemaining / 1000));
|
||||
newTime = currentRemaining + adjustment;
|
||||
if (newTime <= 0) {
|
||||
return Response.json({ error: "Adjustment would reduce the time bank to zero or below" }, { status: 400 });
|
||||
}
|
||||
const newExpiresAt = new Date(now + newTime * 1000);
|
||||
await db
|
||||
.update(schema.draftTimers)
|
||||
.set({ timeRemaining: newTime, picksExpiresAt: newExpiresAt, updatedAt: new Date() })
|
||||
.where(eq(schema.draftTimers.id, currentTimer.id));
|
||||
} else {
|
||||
newTime = currentTimer.timeRemaining + adjustment;
|
||||
if (newTime <= 0) {
|
||||
return Response.json({ error: "Adjustment would reduce the time bank to zero or below" }, { status: 400 });
|
||||
}
|
||||
await db
|
||||
.update(schema.draftTimers)
|
||||
.set({ timeRemaining: newTime, updatedAt: new Date() })
|
||||
.where(eq(schema.draftTimers.id, currentTimer.id));
|
||||
}
|
||||
}
|
||||
|
||||
const team = await db.query.teams.findFirst({
|
||||
|
|
@ -96,17 +118,21 @@ export async function action(args: ActionFunctionArgs) {
|
|||
},
|
||||
});
|
||||
|
||||
try {
|
||||
getSocketIO()
|
||||
.to(`draft-${seasonId}`)
|
||||
.emit("timer-update", {
|
||||
seasonId,
|
||||
teamId,
|
||||
timeRemaining: newTime,
|
||||
currentPickNumber: season.currentPickNumber ?? 1,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Socket.IO error:", error);
|
||||
if (isOnClock) {
|
||||
// Reschedule cancels the old setTimeout and re-reads picksExpiresAt from the DB,
|
||||
// then emits timer-pick-started with the new expiresAt so all clients update their countdown.
|
||||
try {
|
||||
await rescheduleTimer(seasonId);
|
||||
} catch (err) {
|
||||
logger.error("[AdjustTimeBank] rescheduleTimer failed:", err);
|
||||
}
|
||||
} else {
|
||||
// Off-clock team: just push the updated bank to all clients.
|
||||
try {
|
||||
getSocketIO().to(`draft-${seasonId}`).emit("timer-bank-updated", { teamId, timeRemaining: newTime });
|
||||
} catch (error) {
|
||||
logger.error("Socket.IO error:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ success: true, timeRemaining: newTime });
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import { eq } from "drizzle-orm";
|
|||
import { executeAutoPick } from "~/models/draft-utils";
|
||||
import { isCommissioner } from "~/models/commissioner";
|
||||
import { logCommissionerAction } from "~/models/audit-log";
|
||||
import { rescheduleTimer } from "../../../server/timer";
|
||||
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
const pickMadeAt = Date.now(); // capture before any async work for accurate timer credit
|
||||
const { request } = args;
|
||||
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||
const userId = session?.user.id ?? null;
|
||||
|
|
@ -49,12 +51,19 @@ export async function action(args: ActionFunctionArgs) {
|
|||
triggeredBy: "commissioner",
|
||||
commissionerUserId: userId,
|
||||
db,
|
||||
pickMadeAt,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return Response.json({ error: result.error }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await rescheduleTimer(seasonId);
|
||||
} catch {
|
||||
// Non-fatal — recovery interval will pick it up within 30 s
|
||||
}
|
||||
|
||||
const team = await db.query.teams.findFirst({
|
||||
where: eq(schema.teams.id, teamId),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { auth } from "~/lib/auth.server";
|
||||
import { database } from "~/database/context";
|
||||
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 { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
||||
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
|
||||
|
|
@ -13,11 +13,13 @@ import { enqueuePickNotification } from "~/services/discord";
|
|||
import { notifyPickMadeOnDiscord } from "~/services/draft-discord.server";
|
||||
import { logCommissionerAction } from "~/models/audit-log";
|
||||
import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket";
|
||||
import { rescheduleTimer } from "../../../server/timer";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
const pickMadeAt = Date.now(); // capture before any async work for accurate timer credit
|
||||
const { request } = args;
|
||||
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||
const userId = session?.user.id ?? null;
|
||||
|
|
@ -173,41 +175,30 @@ export async function action(args: ActionFunctionArgs) {
|
|||
const isDraftComplete = nextPickNumber > totalPicks;
|
||||
|
||||
// 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 timerSql = season.draftTimerMode === "standard"
|
||||
? sql`${incrementTime}`
|
||||
: sql`${schema.draftTimers.timeRemaining} + ${incrementTime}`;
|
||||
const timerSnapshot = await db.query.draftTimers.findFirst({
|
||||
where: and(eq(schema.draftTimers.seasonId, seasonId), eq(schema.draftTimers.teamId, teamId)),
|
||||
});
|
||||
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
|
||||
.update(schema.draftTimers)
|
||||
.set({ timeRemaining: timerSql, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(schema.draftTimers.seasonId, seasonId),
|
||||
eq(schema.draftTimers.teamId, teamId)
|
||||
)
|
||||
)
|
||||
.set({ timeRemaining: newTimeRemaining, picksExpiresAt: null, picksStartedAt: null, updatedAt: new Date() })
|
||||
.where(and(eq(schema.draftTimers.seasonId, seasonId), eq(schema.draftTimers.teamId, teamId)))
|
||||
.returning();
|
||||
const newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
|
||||
if (!updatedTimer) {
|
||||
await db.insert(schema.draftTimers).values({ seasonId, teamId, timeRemaining: newTimeRemaining });
|
||||
}
|
||||
|
||||
// Emit timer update to all clients
|
||||
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.
|
||||
// rescheduleTimer (called below) will emit timer-pick-started for the next team.
|
||||
|
||||
// Update season's current pick number
|
||||
await db
|
||||
|
|
@ -257,6 +248,9 @@ export async function action(args: ActionFunctionArgs) {
|
|||
isDraftComplete,
|
||||
});
|
||||
|
||||
// Emit timer bank update AFTER pick-made so handlePickMade stops the countdown first.
|
||||
io.to(`draft-${seasonId}`).emit("timer-bank-updated", { teamId, timeRemaining: newTimeRemaining });
|
||||
|
||||
if (isDraftComplete) {
|
||||
io.to(`draft-${seasonId}`).emit("draft-completed");
|
||||
scheduleDraftRoomClosure(seasonId);
|
||||
|
|
@ -301,14 +295,13 @@ export async function action(args: ActionFunctionArgs) {
|
|||
|
||||
// Check if next team has autodraft enabled and trigger immediately
|
||||
if (!isDraftComplete) {
|
||||
try {
|
||||
const updates = await runBracktHarvilleForFantasySeason(seasonId, db);
|
||||
if (updates.length > 0) {
|
||||
getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Brackt EV update after forced manual pick failed:", error);
|
||||
}
|
||||
runBracktHarvilleForFantasySeason(seasonId, db)
|
||||
.then((updates) => {
|
||||
if (updates.length > 0) {
|
||||
getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates });
|
||||
}
|
||||
})
|
||||
.catch((error) => logger.error("Brackt EV update after forced manual pick failed:", error));
|
||||
|
||||
const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) });
|
||||
if (!freshSeason?.draftPaused) {
|
||||
|
|
@ -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({
|
||||
success: true,
|
||||
pick: draftPick,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { auth } from "~/lib/auth.server";
|
|||
import { getTeamForPick } from "~/lib/draft-order";
|
||||
import { database } from "~/database/context";
|
||||
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 { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
||||
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
|
||||
|
|
@ -10,6 +10,7 @@ import { getParticipantsForSeasonWithSports } from "~/models/season-participant"
|
|||
import { getSeasonSportsSimple } from "~/models/season-sport";
|
||||
import { calculatePickInfo, checkAndTriggerNextAutodraft, pruneIneligibleQueueItems } from "~/models/draft-utils";
|
||||
import { getSocketIO, scheduleDraftRoomClosure } from "../../../server/socket";
|
||||
import { rescheduleTimer } from "../../../server/timer";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
|
||||
import { sendOnTheClockEmail } from "~/services/draft-email.server";
|
||||
|
|
@ -18,6 +19,7 @@ import { enqueuePickNotification } from "~/services/discord";
|
|||
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
const pickMadeAt = Date.now(); // capture before any async work for accurate timer credit
|
||||
const { request } = args;
|
||||
const session = await auth.api.getSession({ headers: args.request.headers });
|
||||
const userId = session?.user.id ?? null;
|
||||
|
|
@ -141,13 +143,19 @@ export async function action(args: ActionFunctionArgs) {
|
|||
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({
|
||||
where: and(
|
||||
eq(schema.draftTimers.seasonId, seasonId),
|
||||
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
|
||||
const [draftPick] = await db
|
||||
|
|
@ -161,7 +169,7 @@ export async function action(args: ActionFunctionArgs) {
|
|||
pickInRound,
|
||||
pickedByUserId: userId,
|
||||
pickedByType: isTeamOwner ? "owner" : commissionerRecord ? "commissioner" : "admin",
|
||||
timeUsed: timerSnapshot?.timeRemaining ?? 0,
|
||||
timeUsed: timeRemainingAtPick,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
|
@ -210,45 +218,33 @@ export async function action(args: ActionFunctionArgs) {
|
|||
const isDraftComplete = nextPickNumber > totalPicks;
|
||||
|
||||
// Update the picking team's timer after their pick.
|
||||
// Standard mode: always reset to the per-pick time, regardless of who picked.
|
||||
// Chess clock: always add the increment (any pick type — owner, commissioner, or admin).
|
||||
// Standard mode: always reset to the per-pick time.
|
||||
// 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;
|
||||
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") {
|
||||
// Atomic reset so the timer loop cannot race with this write.
|
||||
[updatedTimer] = await db
|
||||
.update(schema.draftTimers)
|
||||
.set({ timeRemaining: sql`${incrementTime}`, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(schema.draftTimers.seasonId, seasonId),
|
||||
eq(schema.draftTimers.teamId, currentDraftSlot.teamId)
|
||||
)
|
||||
const [updatedTimer] = await db
|
||||
.update(schema.draftTimers)
|
||||
.set(timerUpdateSet)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.draftTimers.seasonId, seasonId),
|
||||
eq(schema.draftTimers.teamId, currentDraftSlot.teamId)
|
||||
)
|
||||
.returning();
|
||||
newTimeRemaining = updatedTimer?.timeRemaining ?? incrementTime;
|
||||
} 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;
|
||||
}
|
||||
)
|
||||
.returning();
|
||||
|
||||
// If the timer row didn't exist yet, seed it.
|
||||
if (!updatedTimer) {
|
||||
await db.insert(schema.draftTimers).values({
|
||||
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)
|
||||
await db
|
||||
.update(schema.seasons)
|
||||
|
|
@ -297,6 +279,13 @@ export async function action(args: ActionFunctionArgs) {
|
|||
isDraftComplete,
|
||||
});
|
||||
|
||||
// Emit timer bank update AFTER pick-made so handlePickMade stops the countdown
|
||||
// interval first — otherwise the interval could tick 0 and overwrite the new bank.
|
||||
getSocketIO().to(`draft-${seasonId}`).emit("timer-bank-updated", {
|
||||
teamId: currentDraftSlot.teamId,
|
||||
timeRemaining: newTimeRemaining,
|
||||
});
|
||||
|
||||
if (isDraftComplete) {
|
||||
getSocketIO().to(`draft-${seasonId}`).emit("draft-completed");
|
||||
scheduleDraftRoomClosure(seasonId);
|
||||
|
|
@ -327,14 +316,14 @@ export async function action(args: ActionFunctionArgs) {
|
|||
|
||||
// Check if next team has autodraft enabled and trigger immediately
|
||||
if (!isDraftComplete) {
|
||||
try {
|
||||
const updates = await runBracktHarvilleForFantasySeason(seasonId, db);
|
||||
if (updates.length > 0) {
|
||||
getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Brackt EV update after pick failed:", error);
|
||||
}
|
||||
// Fire-and-forget so it doesn't delay rescheduleTimer (and timer-pick-started) for the next team.
|
||||
runBracktHarvilleForFantasySeason(seasonId, db)
|
||||
.then((updates) => {
|
||||
if (updates.length > 0) {
|
||||
getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates });
|
||||
}
|
||||
})
|
||||
.catch((error) => logger.error("Brackt EV update after pick failed:", error));
|
||||
|
||||
const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) });
|
||||
if (!freshSeason?.draftPaused) {
|
||||
|
|
@ -353,6 +342,13 @@ export async function action(args: ActionFunctionArgs) {
|
|||
.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({
|
||||
success: true,
|
||||
pick: draftPick,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import * as schema from "~/database/schema";
|
|||
import { isCommissioner } from "~/models/commissioner";
|
||||
import { logCommissionerAction } from "~/models/audit-log";
|
||||
import { getSocketIO } from "../../../server/socket";
|
||||
import { onDraftPaused } from "../../../server/timer";
|
||||
import { logger } from "~/lib/logger";
|
||||
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
|
|
@ -65,6 +66,13 @@ export async function action(args: ActionFunctionArgs) {
|
|||
details: { pickNumber: season.currentPickNumber },
|
||||
});
|
||||
|
||||
// Cancel the in-memory timeout and snapshot remaining time so resume is accurate.
|
||||
try {
|
||||
await onDraftPaused(seasonId);
|
||||
} catch (err) {
|
||||
logger.error("[Pause] onDraftPaused failed:", err);
|
||||
}
|
||||
|
||||
// Emit socket event
|
||||
try {
|
||||
getSocketIO().to(`draft-${seasonId}`).emit("draft-paused", {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import * as schema from "~/database/schema";
|
|||
import { isCommissioner } from "~/models/commissioner";
|
||||
import { logCommissionerAction } from "~/models/audit-log";
|
||||
import { getSocketIO } from "../../../server/socket";
|
||||
import { rescheduleTimer } from "../../../server/timer";
|
||||
import { logger } from "~/lib/logger";
|
||||
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
|
|
@ -65,6 +66,13 @@ export async function action(args: ActionFunctionArgs) {
|
|||
details: { pickNumber: season.currentPickNumber },
|
||||
});
|
||||
|
||||
// Restart the timer from where it was frozen at pause time.
|
||||
try {
|
||||
await rescheduleTimer(seasonId);
|
||||
} catch (err) {
|
||||
logger.error("[Resume] rescheduleTimer failed:", err);
|
||||
}
|
||||
|
||||
// Emit socket event
|
||||
try {
|
||||
getSocketIO().to(`draft-${seasonId}`).emit("draft-resumed", {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { eq, and, gte } from "drizzle-orm";
|
|||
import { isCommissioner } from "~/models/commissioner";
|
||||
import { logCommissionerAction } from "~/models/audit-log";
|
||||
import { getSocketIO } from "../../../server/socket";
|
||||
import { onDraftRolledBack } from "../../../server/timer";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server";
|
||||
|
||||
|
|
@ -77,12 +78,13 @@ export async function action(args: ActionFunctionArgs) {
|
|||
)
|
||||
);
|
||||
|
||||
// Update season: reset pick number and unpause
|
||||
// Update season: reset pick number and pause so the commissioner can review
|
||||
// before picks resume. They must explicitly hit Resume to restart the clock.
|
||||
await db
|
||||
.update(schema.seasons)
|
||||
.set({
|
||||
currentPickNumber: pickNumber,
|
||||
draftPaused: false,
|
||||
draftPaused: true,
|
||||
})
|
||||
.where(eq(schema.seasons.id, seasonId));
|
||||
|
||||
|
|
@ -99,7 +101,10 @@ export async function action(args: ActionFunctionArgs) {
|
|||
});
|
||||
|
||||
try {
|
||||
getSocketIO().to(`draft-${seasonId}`).emit("draft-rolled-back", {
|
||||
const io = getSocketIO();
|
||||
// Roll-back leaves the draft paused so the commissioner reviews before resuming.
|
||||
io.to(`draft-${seasonId}`).emit("draft-paused", { seasonId, paused: true });
|
||||
io.to(`draft-${seasonId}`).emit("draft-rolled-back", {
|
||||
seasonId,
|
||||
pickNumber,
|
||||
teamId: rollbackSlot?.teamId,
|
||||
|
|
@ -117,5 +122,18 @@ export async function action(args: ActionFunctionArgs) {
|
|||
logger.error("[Rollback] Brackt EV update failed:", error);
|
||||
}
|
||||
|
||||
// Reset all timer rows to the initial bank and reschedule the clock from scratch.
|
||||
// Timer rows are not rolled back by the picks delete above, so without this they
|
||||
// would show banked values from the picks that were just deleted (e.g. 2:08, 2:07).
|
||||
const initialTime =
|
||||
season.draftTimerMode === "standard"
|
||||
? (season.draftIncrementTime || 30)
|
||||
: (season.draftInitialTime || 120);
|
||||
try {
|
||||
await onDraftRolledBack(seasonId, initialTime);
|
||||
} catch (err) {
|
||||
logger.error("[Rollback] onDraftRolledBack failed:", err);
|
||||
}
|
||||
|
||||
return Response.json({ success: true, pickNumber });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { isCommissioner } from "~/models/commissioner";
|
|||
import { findUserById, getUserDisplayName } from "~/models/user";
|
||||
import { getSocketIO } from "../../../server/socket";
|
||||
import { startDraft } from "~/services/draft-autostart";
|
||||
import { rescheduleTimer } from "../../../server/timer";
|
||||
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
|
||||
|
|
@ -58,5 +59,13 @@ export async function action(args: ActionFunctionArgs) {
|
|||
return Response.json({ error: result.error }, { status });
|
||||
}
|
||||
|
||||
// Kick off the first pick's timer immediately rather than waiting for the
|
||||
// 30-second recovery interval.
|
||||
try {
|
||||
await rescheduleTimer(seasonId);
|
||||
} catch {
|
||||
// Non-fatal — recovery interval will pick it up within 30 s
|
||||
}
|
||||
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -304,13 +304,20 @@ export default function DraftRoom() {
|
|||
}, [season.status, season.autoStartDraft, season.draftDateTime, nowForPauseCheck]);
|
||||
|
||||
const draftBoardUrl = `/leagues/${season.leagueId}/draft-board/${season.id}`;
|
||||
|
||||
// Capture a client-side timestamp the moment isDraftComplete first becomes true.
|
||||
// When the draft completes via socket event the loader hasn't revalidated yet,
|
||||
// so season.draftCompletedAt is null and we need a fallback baseline for the countdown.
|
||||
const draftCompletedAtClientRef = useRef<number | null>(null);
|
||||
if (isDraftComplete && draftCompletedAtClientRef.current === null) {
|
||||
draftCompletedAtClientRef.current = season.draftCompletedAt?.getTime() ?? Date.now();
|
||||
}
|
||||
|
||||
const roomClosureCountdown = useMemo(() => {
|
||||
if (!isDraftComplete) return null;
|
||||
const completedAt = season.draftCompletedAt;
|
||||
if (!completedAt) return 300;
|
||||
const elapsed = (nowForPauseCheck.getTime() - completedAt.getTime()) / 1000;
|
||||
const remaining = Math.max(0, 300 - elapsed);
|
||||
return Math.ceil(remaining);
|
||||
const baseMs = season.draftCompletedAt?.getTime() ?? draftCompletedAtClientRef.current ?? Date.now();
|
||||
const elapsed = (nowForPauseCheck.getTime() - baseMs) / 1000;
|
||||
return Math.max(0, Math.ceil(300 - elapsed));
|
||||
}, [isDraftComplete, season.draftCompletedAt, nowForPauseCheck]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -493,6 +500,25 @@ export default function DraftRoom() {
|
|||
}, []);
|
||||
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({
|
||||
on,
|
||||
off,
|
||||
|
|
@ -514,6 +540,7 @@ export default function DraftRoom() {
|
|||
setConnectedTeams,
|
||||
setQueue,
|
||||
setTeamTimers,
|
||||
setPickTimerExpiresAt,
|
||||
setIsOvernightPause,
|
||||
setOvernightResumesAt,
|
||||
setWatchedParticipantIds,
|
||||
|
|
@ -530,6 +557,13 @@ export default function DraftRoom() {
|
|||
}
|
||||
}, [sidebarCollapsed]);
|
||||
|
||||
// Revalidate loader data when the draft auto-starts so the pre-draft UI transitions without a refresh.
|
||||
useEffect(() => {
|
||||
const handleDraftStarted = () => revalidate();
|
||||
on("draft-started", handleDraftStarted);
|
||||
return () => off("draft-started", handleDraftStarted);
|
||||
}, [on, off, revalidate]);
|
||||
|
||||
// Queue handlers
|
||||
const handleAddToQueue = useCallback(async (participantId: string) => {
|
||||
if (!userTeam) return;
|
||||
|
|
@ -1019,7 +1053,7 @@ export default function DraftRoom() {
|
|||
const isMyTurn = !!(
|
||||
userTeam && currentDraftSlot && currentDraftSlot.team.id === userTeam.id
|
||||
);
|
||||
const canPick = isMyTurn && season.status === "draft" && !isPaused; // Only team owner on their turn when draft is active
|
||||
const canPick = isMyTurn && season.status === "draft" && !isPaused;
|
||||
|
||||
const currentDraftSlotOwnerName = currentDraftSlot ? ownerMap[currentDraftSlot.team.id] : undefined;
|
||||
const currentClockTime = currentDraftSlot ? teamTimers[currentDraftSlot.team.id] : undefined;
|
||||
|
|
|
|||
|
|
@ -327,7 +327,9 @@ export const draftTimers = pgTable("draft_timers", {
|
|||
teamId: uuid("team_id")
|
||||
.notNull()
|
||||
.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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
282
docs/agents/deadline-based-timer.md
Normal file
282
docs/agents/deadline-based-timer.md
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
# Deadline-based draft timer — implementation guide
|
||||
|
||||
Companion to [`docs/infrastructure-roadmap.md`](../infrastructure-roadmap.md) Phase 1. This is the "how" doc; the roadmap is the "why."
|
||||
|
||||
## The shape of the change
|
||||
|
||||
Today, `server/timer.ts` runs a 1-second `setInterval` that decrements `draftTimers.timeRemaining` and broadcasts `timer-update` events. Everything else (manual picks, admin adjustments, pause/resume) just writes `timeRemaining`; the tick is the only thing that knows about "time."
|
||||
|
||||
After this change, **time becomes a deadline (a `timestamptz`) stored on the `seasons` row**. The server schedules a single `setTimeout` per active draft, sleeps until the deadline, and fires the auto-pick. Per-second writes and per-second broadcasts go away.
|
||||
|
||||
## Schema
|
||||
|
||||
One new nullable column on `seasons`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE seasons ADD COLUMN pick_deadline_at timestamptz;
|
||||
```
|
||||
|
||||
Drizzle:
|
||||
```ts
|
||||
// app/database/schema.ts — seasons table
|
||||
pickDeadlineAt: timestamp("pick_deadline_at", { withTimezone: true }),
|
||||
```
|
||||
|
||||
Semantics:
|
||||
- **NULL** when the draft is paused, pre-draft, completed, or before the first deadline has been written.
|
||||
- **Set** to an absolute UTC timestamp when the current pick is actively counting down.
|
||||
|
||||
`draftTimers.timeRemaining` keeps its existing meaning (per-team remaining seconds, used for chess-clock bank and as "saved seconds while paused"). No other schema changes.
|
||||
|
||||
## The single helper every callsite uses
|
||||
|
||||
The risk in this refactor is forgetting one of the four+ places that write `draftTimers` today. Mitigate by funneling all deadline writes through one helper.
|
||||
|
||||
```ts
|
||||
// app/models/draft-timer.ts
|
||||
|
||||
export interface SetNextPickDeadlineParams {
|
||||
seasonId: string;
|
||||
teamId: string; // the team whose pick this is (for chess-clock bank update)
|
||||
budgetSeconds: number; // seconds until the deadline
|
||||
tx: DbOrTx; // pass the enclosing transaction
|
||||
}
|
||||
|
||||
export async function setNextPickDeadline({
|
||||
seasonId, teamId, budgetSeconds, tx,
|
||||
}: SetNextPickDeadlineParams): Promise<{ deadline: Date }> {
|
||||
const now = new Date();
|
||||
const deadline = new Date(now.getTime() + budgetSeconds * 1000);
|
||||
|
||||
// 1. Write the deadline on the season.
|
||||
await tx.update(schema.seasons)
|
||||
.set({ pickDeadlineAt: deadline })
|
||||
.where(eq(schema.seasons.id, seasonId));
|
||||
|
||||
// 2. Reset the team's timeRemaining so clients/admin tools that read it stay
|
||||
// consistent. Chess-clock callers should pre-compute the new bank and pass it
|
||||
// as budgetSeconds; this function does not know the mode.
|
||||
await tx.update(schema.draftTimers)
|
||||
.set({ timeRemaining: budgetSeconds, updatedAt: now })
|
||||
.where(and(
|
||||
eq(schema.draftTimers.seasonId, seasonId),
|
||||
eq(schema.draftTimers.teamId, teamId),
|
||||
));
|
||||
|
||||
return { deadline };
|
||||
}
|
||||
```
|
||||
|
||||
The scheduler module (below) consumes the returned `{ deadline }` to (re)register the in-memory `setTimeout` and emit the socket event. Keep DB writes inside `tx`; do the in-memory + socket work *after* the transaction commits to avoid scheduling against an aborted write.
|
||||
|
||||
## The scheduler module
|
||||
|
||||
```ts
|
||||
// server/scheduler.ts (rename target for the rewritten server/timer.ts)
|
||||
|
||||
const timers = new Map<string, { deadline: Date; handle: NodeJS.Timeout }>();
|
||||
|
||||
export function scheduleDeadline(seasonId: string, deadline: Date): void {
|
||||
const existing = timers.get(seasonId);
|
||||
if (existing) clearTimeout(existing.handle);
|
||||
|
||||
const delay = Math.max(0, deadline.getTime() - Date.now());
|
||||
const handle = setTimeout(() => fireDeadline(seasonId, deadline), delay);
|
||||
timers.set(seasonId, { deadline, handle });
|
||||
}
|
||||
|
||||
export function cancelDeadline(seasonId: string): void {
|
||||
const existing = timers.get(seasonId);
|
||||
if (!existing) return;
|
||||
clearTimeout(existing.handle);
|
||||
timers.delete(seasonId);
|
||||
}
|
||||
|
||||
async function fireDeadline(seasonId: string, expected: Date): Promise<void> {
|
||||
// Re-read state — the closure may be stale.
|
||||
const season = await db.query.seasons.findFirst({
|
||||
where: eq(schema.seasons.id, seasonId),
|
||||
});
|
||||
if (!season) return; // gone
|
||||
if (season.status !== "draft") return; // completed/cancelled
|
||||
if (season.draftPaused) return; // paused mid-flight
|
||||
if (!season.pickDeadlineAt) return; // paused or cleared
|
||||
if (season.pickDeadlineAt.getTime() !== expected.getTime()) {
|
||||
// Someone changed the deadline (admin adjust, etc). Reschedule, don't fire.
|
||||
return scheduleDeadline(seasonId, season.pickDeadlineAt);
|
||||
}
|
||||
// Deadline still valid — run the auto-pick.
|
||||
await runAutoPickForCurrentTeam(seasonId);
|
||||
}
|
||||
```
|
||||
|
||||
On boot, hydrate from the DB:
|
||||
|
||||
```ts
|
||||
export async function bootstrapScheduler(): Promise<void> {
|
||||
const active = await db.query.seasons.findMany({
|
||||
where: and(
|
||||
eq(schema.seasons.status, "draft"),
|
||||
isNotNull(schema.seasons.pickDeadlineAt),
|
||||
),
|
||||
});
|
||||
for (const s of active) {
|
||||
scheduleDeadline(s.id, s.pickDeadlineAt!);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Already-expired deadlines fire immediately (`Math.max(0, …)` → `setTimeout(0)`), and `executeAutoPick`'s existing idempotency (`(seasonId, pickNumber)` uniqueness with `ON CONFLICT DO NOTHING` in `app/models/draft-utils.ts:629`) protects against duplicate firing if the crash happened mid-pick.
|
||||
|
||||
## Callsite migration
|
||||
|
||||
Five sites need to call `setNextPickDeadline` after this change:
|
||||
|
||||
### 1. Draft auto-start — `app/services/draft-autostart.ts`
|
||||
|
||||
Today: deletes timer rows, re-seeds them with `timeRemaining: initialTime`. After the draft transitions to `status='draft'`, call the helper for pick #1's team:
|
||||
|
||||
```ts
|
||||
const firstTeamId = /* pick 1's team from draftSlots */;
|
||||
const { deadline } = await setNextPickDeadline({
|
||||
seasonId, teamId: firstTeamId, budgetSeconds: initialTime, tx,
|
||||
});
|
||||
// After commit:
|
||||
scheduleDeadline(seasonId, deadline);
|
||||
emitDeadlineEvent(seasonId, deadline);
|
||||
```
|
||||
|
||||
### 2. Manual pick — `app/routes/api/draft.make-pick.ts`
|
||||
|
||||
Today (`:216-262`): updates next picker's `timeRemaining` to `incrementTime` (standard) or `bank + incrementTime` (chess clock). Replace those raw `UPDATE` statements with a call to `setNextPickDeadline` for the *next* team, passing the computed budget. Inside the same transaction as the pick insert.
|
||||
|
||||
### 3. Auto-pick — `server/timer.ts` rewrite
|
||||
|
||||
The old `triggerAutoPick` path becomes `runAutoPickForCurrentTeam` (called from `fireDeadline`). After `executeAutoPick` succeeds, compute the next picker's budget the same way `draft.make-pick.ts` does and call `setNextPickDeadline`. If the just-picked draft completes (no next pick), call `cancelDeadline` and clear `pickDeadlineAt`.
|
||||
|
||||
**Move `while_on` cascade here:** after setting the next deadline, if the next team is `while_on` autodraft, fire `runAutoPickForCurrentTeam` immediately (recursive call), capped at `totalTeams` iterations. Same semantics as today's tick loop.
|
||||
|
||||
### 4. Admin time-bank adjust — `app/routes/api/draft.adjust-time-bank.ts`
|
||||
|
||||
Today (`:76-78`): writes new `timeRemaining`. After the rewrite, if the team being adjusted is the *current* picker, also update `pickDeadlineAt` to reflect the new budget:
|
||||
|
||||
```ts
|
||||
if (teamId === currentPickerTeamId && !season.draftPaused) {
|
||||
await setNextPickDeadline({ seasonId, teamId, budgetSeconds: newTime, tx });
|
||||
scheduleDeadline(seasonId, /* new deadline */);
|
||||
}
|
||||
```
|
||||
|
||||
For non-current teams, only `timeRemaining` changes (no deadline impact).
|
||||
|
||||
### 5. Pause / Resume
|
||||
|
||||
- **Pause:** compute remaining = `pickDeadlineAt − now()`, write into `draftTimers.timeRemaining`, null out `seasons.pickDeadlineAt`, call `cancelDeadline(seasonId)`.
|
||||
- **Resume:** call `setNextPickDeadline` with `budgetSeconds = current draftTimers.timeRemaining`. Same code path as a fresh pick start.
|
||||
|
||||
## Overnight pause
|
||||
|
||||
Existing logic in `server/timer.ts` (`checkOvernightPause`, `isInOvernightWindow`, `getOvernightResumeUTC`) ports cleanly. When `setNextPickDeadline` is about to write a deadline that falls inside an overnight window, instead write `deadline = resumeAtUTC`, and include a `frozen: true` flag in the socket event so the UI shows the pause indicator. The scheduler doesn't need to know — it just wakes up at `resumeAtUTC`.
|
||||
|
||||
Edge: if a pause window crosses an admin time-bank adjust, the recompute path naturally re-evaluates the window. Always go through `setNextPickDeadline`.
|
||||
|
||||
## Client side
|
||||
|
||||
### Socket event
|
||||
|
||||
Replace the per-second `timer-update` with a one-shot `deadline-update`:
|
||||
|
||||
```ts
|
||||
// shared types
|
||||
interface DeadlineUpdate {
|
||||
seasonId: string;
|
||||
teamId: string;
|
||||
pickNumber: number;
|
||||
deadline: string; // ISO timestamp
|
||||
serverNow: string; // ISO timestamp at emit time
|
||||
frozen?: boolean; // overnight pause
|
||||
resumesAtUTC?: number; // when frozen
|
||||
}
|
||||
```
|
||||
|
||||
(You can keep the `timer-update` name if you'd rather minimize churn — there's exactly one consumer, `useDraftSocketEvents.ts:261`. Renaming makes the migration grep-able; not renaming makes the diff smaller. Suggest renaming.)
|
||||
|
||||
### Handler
|
||||
|
||||
`useDraftSocketEvents.ts:handleTimerUpdate` becomes:
|
||||
|
||||
```ts
|
||||
const handleDeadlineUpdate = (data: DeadlineUpdate) => {
|
||||
const offset = new Date(data.serverNow).getTime() - Date.now();
|
||||
setTimerState({
|
||||
deadlineMs: new Date(data.deadline).getTime(),
|
||||
serverOffsetMs: offset,
|
||||
pickNumber: data.pickNumber,
|
||||
frozen: data.frozen ?? false,
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### Countdown rendering
|
||||
|
||||
Wherever the draft room renders the countdown, compute locally:
|
||||
|
||||
```ts
|
||||
const remainingMs = timerState.deadlineMs - (Date.now() + timerState.serverOffsetMs);
|
||||
const remainingSeconds = Math.max(0, Math.ceil(remainingMs / 1000));
|
||||
```
|
||||
|
||||
Drive re-renders via `requestAnimationFrame` (smoother) or a 1Hz `setInterval` (cheaper, matches today's perceived cadence). Force a recompute on `visibilitychange → visible` so tab-backgrounded users get an accurate countdown on return.
|
||||
|
||||
### Loader
|
||||
|
||||
The draft room loader (`app/routes/leagues/$leagueId.draft.$seasonId.tsx`) must return `pickDeadlineAt` (and a server timestamp) alongside the existing payload. The reconnect path (`useDraftSocket`'s `revalidate` on reconnect) then re-initializes the timer without waiting for a socket event.
|
||||
|
||||
## Migration strategy
|
||||
|
||||
Two-stage deploy to keep correctness rollback-able:
|
||||
|
||||
**Stage 1 — shadow writes (1 day in production).**
|
||||
1. Add the column.
|
||||
2. Update all five callsites to call `setNextPickDeadline` (which writes `pickDeadlineAt`).
|
||||
3. Leave the 1-second `setInterval` running as the authoritative auto-pick path.
|
||||
4. Add a metric: at each tick, log `actualPickDeadline = now() + timeRemaining` vs the stored `pickDeadlineAt`. They should agree to within ~1 second.
|
||||
5. Observe a draft or two. Spot-check the metric.
|
||||
|
||||
**Stage 2 — flip authority.**
|
||||
1. Replace `startDraftTimerSystem()` with `bootstrapScheduler()` in `server.ts`.
|
||||
2. The scheduler is now authoritative; the 1-second interval is gone.
|
||||
3. Roll out. Confirm `deadline-fire accuracy` metric (actual vs scheduled fire time) stays under 100ms.
|
||||
|
||||
**Rollback:** revert the deploy. The `pickDeadlineAt` column remains but is unused; `timeRemaining` semantics are unchanged, so the old tick resumes correctly.
|
||||
|
||||
## Tests
|
||||
|
||||
New tests (add alongside existing `server/__tests__/timer-*.test.ts`):
|
||||
|
||||
1. **Idempotency on restart.** Insert a draft with `pickDeadlineAt` in the past and an existing pick at `pickNumber`. Call `bootstrapScheduler()`. Assert no duplicate pick is created (relies on `executeAutoPick`'s existing `ON CONFLICT DO NOTHING`).
|
||||
2. **Stale closure reschedule.** Schedule a deadline, mutate `pickDeadlineAt` in the DB to a later time, advance fake timers to the original deadline. Assert `fireDeadline` reschedules instead of picking.
|
||||
3. **Pause during in-flight.** Schedule a deadline, set `draftPaused=true` in the DB, advance fake timers. Assert no pick is made.
|
||||
4. **Helper invariant.** Every test that simulates a pick (manual or auto) asserts that *both* `seasons.pickDeadlineAt` and `draftTimers.timeRemaining` were updated. This catches future callsites that bypass `setNextPickDeadline`.
|
||||
5. **`while_on` cascade.** Three teams all in `while_on` mode; one manual pick by team 1 should cascade to picks by teams 2 and 3 immediately, then schedule the next deadline.
|
||||
6. **Overnight pause covers deadline.** Set an overnight window 5 minutes before the computed deadline. Assert the stored deadline equals `resumeAtUTC` (not `now + budget`) and the event payload has `frozen: true`.
|
||||
|
||||
Existing tests under `server/__tests__/` and `app/models/__tests__/executeAutoPick.timer.test.ts` should still pass — verify before flipping authority.
|
||||
|
||||
## Observability
|
||||
|
||||
Replace the Phase 0 "tick drift" metric:
|
||||
|
||||
```ts
|
||||
// At fireDeadline entry:
|
||||
const drift = Date.now() - expectedDeadline.getTime();
|
||||
logger.log("[Scheduler] deadline fired", { seasonId, driftMs: drift });
|
||||
```
|
||||
|
||||
`driftMs > 500` is the new "tick drift > 1100ms." Anything above a few hundred ms means the event loop was blocked at the moment of firing — worth investigating but not user-visible (the re-read guard handles arbitrary drift correctly).
|
||||
|
||||
## What this does *not* solve
|
||||
|
||||
- **Multi-instance scheduling.** Two web instances both calling `bootstrapScheduler()` would both schedule `setTimeout`s for the same draft, leading to duplicate auto-pick attempts. Idempotency saves correctness but you'd get duplicate broadcasts. Phase 5 problem, not Phase 1; document the "single scheduler instance" assumption in `server/scheduler.ts`.
|
||||
- **Scheduled-draft auto-start.** `checkAndAutoStartDrafts` (currently called every tick) needs its own home. Easiest: a separate low-frequency (30–60s) `setInterval` in `server.ts`. Rare event, doesn't need precision.
|
||||
- **Pick-completion broadcast volume.** The existing `pick-made` event still fires per pick; this refactor doesn't change that. Only the per-second `timer-update` chatter goes away.
|
||||
281
docs/infrastructure-roadmap.md
Normal file
281
docs/infrastructure-roadmap.md
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
# Infrastructure Roadmap for brackt.com
|
||||
|
||||
## Context
|
||||
|
||||
brackt.com today runs as a **single Node.js process** that bundles three concerns:
|
||||
|
||||
1. The Express + React Router 7 SSR web server (`server.ts`)
|
||||
2. A 1-second `setInterval` tick loop driving draft timers and auto-picks (`server/timer.ts`)
|
||||
3. A Socket.IO server with **in-memory** room/connection state (`server/socket.ts`)
|
||||
|
||||
Plus a 24h snapshot interval (`server/snapshots.ts`). There is no job queue, no scheduler beyond `setInterval`, and no pub/sub layer. Deploys drop all socket connections; a crash takes down drafts, web, and the tick simultaneously.
|
||||
|
||||
Based on the discovery questions:
|
||||
|
||||
- **No HA fire.** Drafts are infrequent enough that a 30-min outage isn't catastrophic *yet*.
|
||||
- **Zero-downtime deploys are the immediate pain.** Connections drop on every release.
|
||||
- **Low concurrency today** (<5 live drafts, <20 in 6mo) — capacity is not the constraint.
|
||||
- **Avoid Redis** unless clearly justified; prefer Postgres-backed.
|
||||
- **Go rewrite is speculative.** No measurements show Node is the bottleneck.
|
||||
|
||||
The roadmap below sequences work by **risk-adjusted value**: cheap, reversible wins first; expensive rewrites only when data demands them.
|
||||
|
||||
---
|
||||
|
||||
## Current production topology
|
||||
|
||||
What's actually deployed today (per `.production-info/docker-compose-production.yml`):
|
||||
|
||||
- **Single `brackt` container** running on Vultr, image pulled from `sjc.vultrcr.com/chrisparsons/brackt:latest`.
|
||||
- **One-shot `migrate` container** runs `node /app/scripts/migrate.mjs` before brackt starts (`depends_on: condition: service_completed_successfully`).
|
||||
- **Traefik** is already fronting the app — TLS via Let's Encrypt, `brackt.com` apex + `www` → apex redirect, port 3000.
|
||||
- **External Postgres** (not in this compose). `PGBOUNCER=true` is set in env — the app connects through Vultr's built-in pgbouncer. Vultr supports per-pool mode selection (session/transaction/statement), so worst case is "add a second pool" rather than re-architecting.
|
||||
- **No observability stack.** No Prometheus, Loki, Grafana, no log shipper. Logs live in `docker logs brackt`.
|
||||
- **No Redis, no separate worker, no tick container.** Everything is one process.
|
||||
- `restart_policy: condition: on-failure, max_attempts: 5` — after 5 crashes the container stays down with no alerting.
|
||||
- **Hidden blockers for any multi-replica future:** `container_name: brackt` is hardcoded (Docker won't allow two containers with the same name), no healthcheck on the brackt service, and the image tag is `:latest` (rollbacks are painful and you can't tell which replica is on which version mid-deploy).
|
||||
|
||||
These facts shape the phases below — particularly the deadline-based timer recommendation that sidesteps several of these constraints entirely.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Phase 0 — Instrumentation (1–2 days) ⭐ do this first
|
||||
|
||||
You cannot make further sequencing decisions without data. Almost everything below ("is the tick slow?", "do we need multi-instance?", "is the timer refactor working?") becomes guesswork without basic metrics.
|
||||
|
||||
**Add:**
|
||||
- Tick drift measurement in `server/timer.ts` — log when actual interval > 1100ms.
|
||||
- Auto-pick latency: time from `timeRemaining <= 0` → `executeAutoPick()` resolved.
|
||||
- Concurrent socket connections + active draft rooms gauge.
|
||||
- Basic HTTP request duration histogram (any lightweight middleware).
|
||||
|
||||
**Where to look at the data — pick one, don't leave this abstract:**
|
||||
|
||||
| Option | Effort | What you get |
|
||||
|---|---|---|
|
||||
| Structured JSON logs to stdout, view via `docker logs brackt \| jq` | ~2 hours | Good enough for low-volume signals (tick drift, auto-pick latency). Start here. |
|
||||
| Grafana + Loki + Promtail as sidecars in the compose | ~1 day | Real dashboards, log retention, query history |
|
||||
| Hosted (Axiom / Better Stack / Grafana Cloud free tier) | ~half day | No infra to run; ship logs over the wire |
|
||||
|
||||
**Recommendation:** start with structured JSON + `jq` for a week, escalate only if you find yourself wanting historical queries you can't get from logs.
|
||||
|
||||
**Why first:** every decision below is currently a guess. A week of metrics turns guesses into decisions, *and* gives the Phase 1 timer refactor a baseline to compare against.
|
||||
|
||||
---
|
||||
|
||||
### Phase 1 — Deadline-based draft timer (3–5 days) ⭐ biggest win
|
||||
|
||||
**The problem:** the 1-second `setInterval` in `server/timer.ts` is the *cause* of nearly every downstream problem in this roadmap. Per active draft, every second it issues an UPDATE on `draftTimers`, several SELECTs, and a `timer-update` socket broadcast. It also makes the process un-killable without dropping drafts, makes multi-instance hard, and is the only thing in this stack that looks like it might need Go.
|
||||
|
||||
**The insight:** the timer state is *deterministic from a deadline*. If you store `pickDeadlineAt` (absolute UTC) instead of `timeRemaining` (seconds), the remaining time at any moment is just `pickDeadlineAt − now()`. The server doesn't need to tick — it only needs to wake up *at the deadline* to run the auto-pick.
|
||||
|
||||
**The change:**
|
||||
|
||||
Schema (one new column total):
|
||||
```
|
||||
seasons
|
||||
+ pickDeadlineAt timestamptz NULL -- the active deadline for the current pick;
|
||||
-- NULL while paused, pre-draft, or completed
|
||||
```
|
||||
Why on `seasons` and not `draftTimers`: only one team's deadline matters at a time (the current picker's). One row per draft matches the semantics and gives a trivial bootstrap query (`SELECT id FROM seasons WHERE status='draft' AND pickDeadlineAt IS NOT NULL`). The existing `draftTimers.timeRemaining` stays where it is — still needed for chess-clock bank tracking and as the "saved remaining seconds while paused" storage.
|
||||
|
||||
Server behavior — funnel **every** deadline write through a single helper:
|
||||
```ts
|
||||
async function setNextPickDeadline(tx, { seasonId, teamId, budgetSeconds }) {
|
||||
// 1. UPDATE seasons SET pickDeadlineAt = now() + budgetSeconds
|
||||
// 2. UPDATE draftTimers SET timeRemaining = budgetSeconds (chess-clock: add increment)
|
||||
// 3. (re)schedule in-memory setTimeout for this seasonId
|
||||
// 4. emit { deadline, serverNow } on draft-${seasonId}
|
||||
}
|
||||
```
|
||||
Callsites (audit surface):
|
||||
- **Draft auto-start** (`app/services/draft-autostart.ts`) — first pick's deadline.
|
||||
- **Manual pick** (`app/routes/api/draft.make-pick.ts`) — next pick's deadline (currently writes `timeRemaining` only).
|
||||
- **Auto-pick** (`server/timer.ts` rewrite) — next pick's deadline after auto-picking.
|
||||
- **Admin time-bank adjust** (`app/routes/api/draft.adjust-time-bank.ts`) — recompute `pickDeadlineAt` to match new `timeRemaining`.
|
||||
- **Resume from pause** — same as auto-start but for the current picker.
|
||||
|
||||
Other behaviors:
|
||||
- **Process restart:** on boot, query active drafts where `pickDeadlineAt IS NOT NULL` and re-schedule a `setTimeout` for each. Already-expired deadlines auto-pick immediately.
|
||||
- **Pause:** save remaining into existing `draftTimers.timeRemaining` (`= pickDeadlineAt − now()`), null out `seasons.pickDeadlineAt`, clear the in-memory timeout. **No new "paused remaining" column needed** — `timeRemaining` already serves this purpose.
|
||||
- **Overnight pause:** when scheduling, if the deadline falls inside the pause window, schedule for `resumeAtUTC` instead and emit a `frozen` flag.
|
||||
- **`while_on` autodraft:** unchanged semantics — fire immediately on pick transition, capped at `totalTeams` iterations. The logic moves from the tick to the pick-completion handler (inside `setNextPickDeadline` or just before it).
|
||||
|
||||
Client behavior:
|
||||
- Receive `{ deadline, serverNow }`; compute clock offset once.
|
||||
- Render countdown locally from `deadline − clientNow() + offset`. Already has to do this between 1Hz emits today, so the interpolation code likely exists.
|
||||
|
||||
**Wins vs today:**
|
||||
|
||||
| | 1s setInterval (today) | Deadline-based |
|
||||
|---|---|---|
|
||||
| DB writes per active draft per pick | ~30–120 | 1 |
|
||||
| Socket emits per active draft per pick | ~30–120 | 1 (deadline) + 1 (pick made) |
|
||||
| Server wake-ups per second | 1 always | 0 (sleeps until deadline) |
|
||||
| Survives process restart | dies | one-line bootstrap query |
|
||||
| Cross-process / multi-instance | hard (singleton tick required) | trivial (whichever instance handles the pick schedules the next deadline) |
|
||||
|
||||
**Critical constraints:**
|
||||
- **Single scheduler assumption.** Phase 1 runs in one process. Multi-instance leader-election (per-`seasonId` advisory lock + claim metadata) is deferred to Phase 5. Document this assumption explicitly in `server/timer.ts`.
|
||||
- **Clock skew.** Send `serverNow` with each deadline event so clients can correct.
|
||||
- **Reconnect path.** The loader must return `pickDeadlineAt` (and `serverNow`) so clients re-initializing after a reconnect render the correct countdown without waiting for a socket event.
|
||||
- **Migration safety.** Run both systems in parallel for one draft (deadline-based writes the new column; old setInterval still drives picks). Compare. Flip when confident.
|
||||
|
||||
**Why this matters more than splitting the tick into its own process:** it dissolves the problem rather than relocating it. Once the tick is deadline-based and idle 99% of the time, splitting it into its own container becomes a deploy-isolation play, not a correctness one. And the Phase 5 Go rewrite goes off the table permanently — there is no hot loop left to optimize.
|
||||
|
||||
**Files:** `server/timer.ts` (rewrite as a scheduler module), `app/database/schema.ts` + new migration (single column on `seasons`), `app/models/draft-timer.ts` (new `setNextPickDeadline` helper — see implementation guide), `app/services/draft-autostart.ts` (call helper on draft start), `app/routes/api/draft.make-pick.ts` (call helper on manual pick), `app/routes/api/draft.adjust-time-bank.ts` (recompute deadline on adjust), `app/hooks/useDraftSocketEvents.ts` (sole `timer-update` consumer — switch to deadline event), draft room loader (`app/routes/leagues/$leagueId.draft.$seasonId.tsx`), socket event types.
|
||||
|
||||
**See [`docs/agents/deadline-based-timer.md`](agents/deadline-based-timer.md) for the implementation guide** — schema, helper signature, callsite-by-callsite migration, test plan.
|
||||
|
||||
**Gotchas to handle (load-bearing for correctness):**
|
||||
|
||||
1. **Stale in-memory `setTimeout` after out-of-band DB writes.** Anything that mutates `pickDeadlineAt` outside the scheduler (admin extend, retro edit) leaves the captured closure stale. *Mitigation:* the auto-pick callback must re-read the row and verify `pickDeadlineAt` still equals the expected value before firing; if changed, reschedule and return. This single guard also handles wall-clock vs. monotonic drift (NTP jumps, VM resume): the re-check catches "deadline already passed" and "deadline moved" with the same code path.
|
||||
2. **Pause-during-flight race.** `setTimeout` fires → enters auto-pick logic → admin pauses concurrently. The re-read in (1) must also check `draftPaused` before proceeding. Contract: in-flight picks complete; future scheduled picks don't fire after pause.
|
||||
3. **Idempotency on restart.** Bootstrap query (`pickDeadlineAt IS NOT NULL`) fires all expired deadlines on boot. Idempotency is **already guaranteed** by `executeAutoPick` (`app/models/draft-utils.ts:499–512` pre-check + `:629–650` `ON CONFLICT DO NOTHING` on `(seasonId, pickNumber)`). Add an explicit test: "auto-pick is idempotent across process restart."
|
||||
4. **Transaction boundary.** Pick row + next `pickDeadlineAt` write must share one transaction. Otherwise a crash between them leaves the draft "completed pick / no deadline" — the bootstrap query won't catch it.
|
||||
|
||||
**Lower-severity edge cases (worth knowing, not blocking):**
|
||||
|
||||
- **`setTimeout` 24.8-day overflow:** irrelevant for per-pick deadlines; *don't* reuse this primitive for `season.draftDateTime` auto-start — keep that on a low-frequency (30–60s) `setInterval` poll.
|
||||
- **`while_on` cascade** moves from the tick to the pick-completion handler: check next team's autodraft mode before scheduling next deadline; fire immediately if `while_on`, capped at `totalTeams` iterations.
|
||||
- **Client tab backgrounding** is *better* than today: browsers throttle timers, but `visibilitychange → visible` triggers a recompute from the current deadline, so countdowns snap to truth instead of waiting for the next server emit.
|
||||
- **Clock skew:** server includes `serverNow` with every deadline event; client computes a one-time offset.
|
||||
- **Migration of in-flight drafts:** two-stage deploy — first ship shadow writes alongside the existing tick (observe a day), then flip the authoritative path and drop the interval.
|
||||
- **Multi-instance scheduling** (Phase 5 inheritance): defer the leader-election design; document the "single scheduler" assumption explicitly. The bootstrap query recovers a crashed scheduler on restart, which is sufficient for one instance.
|
||||
- **Observability swap:** the Phase 0 "tick drift" metric becomes meaningless after Phase 1. Replace with **deadline-fire accuracy** = `actualFireTime − scheduledFireTime`. Same intent.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Scheduled work: snapshots off `setInterval` (1 day)
|
||||
|
||||
**The problem:** `server/snapshots.ts` uses a 24h `setInterval` that dies with the process, has no retry, and no visibility. As you add standings sync, scoring imports, EV snapshots, etc., this gets worse.
|
||||
|
||||
**The realistic options** (the original roadmap assumed pg-boss; given the production constraints, simpler choices win here):
|
||||
|
||||
| Option | Pros | Cons | Verdict |
|
||||
|---|---|---|---|
|
||||
| **External HTTP cron** (healthchecks.io free / GitHub Actions `schedule:` / cron-job.org) hitting `POST /admin/jobs/run-daily-snapshots` | Zero new infra; dead-man-switch alerting built-in; sidesteps pgbouncer entirely | External dependency; needs auth on the endpoint | ⭐ start here |
|
||||
| `node-cron` in a small worker container with Postgres advisory lock for singleton | Self-contained; trivial; no 3rd-party | Build basic retry/durability yourself; another container | Good middle ground |
|
||||
| `pg-boss` | Real job system, retries, audit table | Uses `LISTEN/NOTIFY` and prepared statements — incompatible with pgbouncer transaction pooling. **On Vultr, this is solvable:** create a second session-mode pool just for pg-boss and pass its DSN to that workload only. No built-in UI. | Defer until you have 5+ scheduled jobs |
|
||||
| `pg_cron` extension | Runs inside Postgres; rock solid; no scheduler process. **Supported on Vultr** (`CREATE EXTENSION IF NOT EXISTS pg_cron;`). | Logic lives in SQL; for app-level work you'd call out via `pg_net → POST /admin/jobs/snapshots`, which is just external HTTP cron with extra steps | Skip for one job; reconsider if you accumulate many |
|
||||
|
||||
**Recommendation:** external HTTP cron. The "job" becomes an authenticated `POST /admin/jobs/run-daily-snapshots` route that calls the existing `createSnapshotsForAllSeasons()`. Healthchecks.io's free tier gives you "you didn't run today" alerts for free.
|
||||
|
||||
**Why now (not later):** it's a 1-hour change once you have an admin endpoint, and it removes the last `setInterval` from `server.ts` — which makes Phase 3 (process restart safety) trivially correct rather than "hopefully correct."
|
||||
|
||||
**Files:** new admin route `app/routes/admin/jobs.run-daily-snapshots.tsx` (POST handler with shared-secret auth), remove `startSnapshotSystem()` call from `server.ts`, delete or repurpose `server/snapshots.ts`.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Production hardening prerequisites (1–2 days)
|
||||
|
||||
Before any multi-replica / zero-downtime work, the production compose needs three small fixes. These are cheap and worth doing regardless of what comes next.
|
||||
|
||||
**The changes:**
|
||||
1. **Remove hardcoded `container_name: brackt`.** Docker rejects duplicate names — this alone blocks scaling to 2 replicas. Use the service name + replica index (Compose handles this automatically when `container_name` is omitted).
|
||||
2. **Add a healthcheck** to the brackt service (e.g., `GET /healthz` returns 200 once the server is listening and DB is reachable). Without this, Traefik will route to a half-started container during rolling deploys.
|
||||
3. **Pin the image tag.** Move off `:latest` to a Git SHA or version. Rollbacks become a one-line edit; you can tell which replica is running which version during a rolling deploy.
|
||||
|
||||
**Files:** `.production-info/docker-compose-production.yml`, new `app/routes/healthz.tsx`, deploy script (whatever sets the tag).
|
||||
|
||||
---
|
||||
|
||||
### Phase 4 — Optional: split tick into its own process (1 week, no longer urgent)
|
||||
|
||||
**Status after Phase 1:** the "tick" is now an idle process that wakes up at deadlines. It can be co-located with web indefinitely without correctness or performance issues.
|
||||
|
||||
**When to revisit:** if you want pure deploy isolation — restart the web tier without touching the timer scheduler at all. This is a nice-to-have, not a blocker.
|
||||
|
||||
**The change (if/when):**
|
||||
- Extract timer scheduling into `server/tick.ts` standalone entrypoint.
|
||||
- Run as a second container in compose.
|
||||
- Tick still emits via Socket.IO — either via Postgres `LISTEN/NOTIFY` to the web tier (⚠️ verify pgbouncer mode supports session-scoped listeners) or via a pending-broadcasts table the web tier polls. With Phase 1 done, the broadcast volume is tiny — polling is fine.
|
||||
- Singleton enforced via Postgres advisory lock (same primitive Phase 1 already uses for per-draft scheduling).
|
||||
|
||||
**Why this is downgraded vs. the old roadmap:** Phase 1 already removed the per-second hot loop. The remaining motivation is purely "I want to deploy web independently of timer code." That's nice but not load-bearing.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5 — Zero-downtime web deploys via Traefik + sticky sessions (2–3 days)
|
||||
|
||||
**The problem:** even after Phase 1, restarting the web container still drops sockets.
|
||||
|
||||
**The change:**
|
||||
- Run **2 web containers** behind Traefik (prerequisites from Phase 3 must be done first).
|
||||
- Enable **sticky sessions** in Traefik via labels: `traefik.http.services.brackt.loadbalancer.sticky.cookie=true`. Socket.IO needs this — clients must return to the same instance until you add a pub/sub adapter.
|
||||
- Rolling deploy: take instance A out of Traefik's pool → wait for connections to drain (or hard-cut, since reconnect works) → deploy A → put back in pool → repeat for B.
|
||||
- This works **without Redis** because each draft room's clients are sticky to one instance.
|
||||
- For broadcasts that need to reach *both* instances (e.g., timer scheduled on instance A, but admin action on instance B): use Postgres `LISTEN/NOTIFY` (⚠️ verify pgbouncer mode first) or a pending-broadcasts table.
|
||||
|
||||
**Why this works at your scale:** with <20 concurrent drafts, a single web instance has zero capacity issues. The second instance is purely for deploy rotation.
|
||||
|
||||
**Files:** `.production-info/docker-compose-production.yml` (replicas + sticky labels), deploy script.
|
||||
|
||||
---
|
||||
|
||||
### Phase 6 — Defer: Socket.IO Redis adapter (only when needed)
|
||||
|
||||
**When to revisit:** if you ever want a single draft's clients to span multiple web instances (e.g., 50+ concurrent connections per draft, or geo-distributed users). You're nowhere near that.
|
||||
|
||||
**If/when you do:** add Redis, install `@socket.io/redis-adapter`, drop sticky sessions. ~1-day change if Phases 1–5 are clean.
|
||||
|
||||
---
|
||||
|
||||
### ~~Phase 7 — Go tick rewrite~~ (removed)
|
||||
|
||||
Phase 1 (deadline-based timer) eliminates the hot loop the Go rewrite was meant to optimize. The remaining scheduler is `setTimeout` and a Postgres query — Node handles this for free.
|
||||
|
||||
If you want to learn Go, pick a greenfield side service (a webhook receiver for scoring feeds, for instance). Don't put it on the critical path of live drafts.
|
||||
|
||||
---
|
||||
|
||||
## Sequencing Summary
|
||||
|
||||
| # | Phase | Effort | Unblocks |
|
||||
|---|---|---|---|
|
||||
| 0 | Instrumentation | 1–2d | Every decision below |
|
||||
| 1 | **Deadline-based draft timer** | 3–5d | Eliminates the 1s tick; makes everything else easier |
|
||||
| 2 | Snapshots → external HTTP cron | 1d | Removes the last `setInterval`; clean restart semantics |
|
||||
| 3 | Prod hardening (no `container_name`, healthcheck, pinned tag) | 1–2d | Required for any multi-replica work |
|
||||
| 4 | Split tick into own process | ~1wk | Optional after Phase 1; deploy isolation only |
|
||||
| 5 | 2 web instances + Traefik sticky sessions | 2–3d | Zero-downtime deploys (the actual goal) |
|
||||
| 6 | Redis + socket.io adapter | *deferred* | Cross-instance fan-out (not needed at current scale) |
|
||||
|
||||
**Total to "I can deploy without dropping connections": ~2 weeks of focused work** (Phases 0, 1, 2, 3, 5; Phase 4 optional).
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
**Resolved (Vultr managed Postgres):**
|
||||
|
||||
- **Pgbouncer modes:** Vultr ships pgbouncer built-in and supports all three modes (session, transaction, statement). You can create **multiple pools with different modes per cluster** via the Vultr panel — so the "pgbouncer incompatibility" concern for pg-boss / `LISTEN/NOTIFY` is *not* a hard blocker; it's "use a session-mode pool for those workloads, transaction-mode for the main app." *Action when relevant:* confirm what pool mode `PGBOUNCER=true` currently routes through (transaction is the typical default) and add a second session-mode pool the day you adopt pg-boss or `LISTEN/NOTIFY`.
|
||||
- **`pg_cron`:** supported on Vultr (`CREATE EXTENSION IF NOT EXISTS pg_cron;`). Doesn't change the Phase 2 recommendation (external HTTP cron remains simpler for one job) but stays in the toolbox.
|
||||
|
||||
**Still open:**
|
||||
|
||||
- **Where do logs/metrics go?** Pick one in Phase 0 and commit.
|
||||
|
||||
---
|
||||
|
||||
## Critical files
|
||||
|
||||
- `server.ts` — main entrypoint; stops calling `startSnapshotSystem` (Phase 2); timer system becomes deadline-scheduler (Phase 1).
|
||||
- `server/timer.ts` — rewrite to deadline-based scheduler (Phase 1); add tick drift metric *before* the rewrite to baseline (Phase 0).
|
||||
- `server/snapshots.ts` — delete or repurpose; logic moves behind an admin endpoint (Phase 2).
|
||||
- `server/socket.ts` — emit `{ deadline, serverNow }` events instead of per-second `timer-update` (Phase 1).
|
||||
- `app/hooks/useDraftSocketEvents.ts` and draft room route — client-side countdown interpolation from deadline (Phase 1).
|
||||
- `app/database/schema.ts` — add `pickDeadlineAt` to `seasons` (Phase 1).
|
||||
- `.production-info/docker-compose-production.yml` — remove `container_name`, add healthcheck, pin tag, then add replicas + sticky labels (Phases 3, 5).
|
||||
- New `app/routes/admin/jobs.run-daily-snapshots.tsx` — HTTP-triggered snapshot job (Phase 2).
|
||||
- New `app/routes/healthz.tsx` — Traefik healthcheck target (Phase 3).
|
||||
|
||||
## Verification
|
||||
|
||||
- **Phase 0:** confirm metrics emit (grep logs for tick drift, auto-pick latency); leave running for a week before Phase 1 ships.
|
||||
- **Phase 1:** run side-by-side mode in a staging draft — deadline-based writes shadow columns, setInterval still drives picks; confirm zero drift, then flip. Then: restart the process mid-draft, confirm timer keeps the right deadline and auto-pick fires at the original time.
|
||||
- **Phase 2:** kill the web process mid-day; confirm the external cron still triggers tomorrow and the endpoint runs successfully.
|
||||
- **Phase 3:** `curl /healthz` returns 200 only after DB is reachable; deploy with a pinned tag and confirm you can roll back by changing one line.
|
||||
- **Phase 5:** rolling-deploy both web instances during a staging draft; users see at most a single brief reconnect (or none, with proper drain).
|
||||
2
drizzle/0116_panoramic_human_cannonball.sql
Normal file
2
drizzle/0116_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;
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"id": "b3134856-5ae5-431c-835a-69320a647f28",
|
||||
"id": "0116_panoramic_human_cannonball",
|
||||
"prevId": "a26388b5-0b00-4107-9e26-4b4f4ed8dc31",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
|
|
@ -860,6 +860,18 @@
|
|||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"picks_expires_at": {
|
||||
"name": "picks_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"picks_started_at": {
|
||||
"name": "picks_started_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
|
|
@ -2,817 +2,122 @@
|
|||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1732076135211,
|
||||
"tag": "0000_short_donald_blake",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1760165302772,
|
||||
"tag": "0001_serious_la_nuit",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1760166775427,
|
||||
"tag": "0002_tired_iron_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1760167848365,
|
||||
"tag": "0003_public_shadowcat",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1760168383812,
|
||||
"tag": "0004_spicy_zarda",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1760169537260,
|
||||
"tag": "0005_abnormal_thunderbird",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1760241924414,
|
||||
"tag": "0006_needy_junta",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1760327817376,
|
||||
"tag": "0007_pink_nebula",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1760375514773,
|
||||
"tag": "0008_salty_trauma",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1760392647508,
|
||||
"tag": "0009_fuzzy_korvac",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1760469105799,
|
||||
"tag": "0010_complex_the_stranger",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1760501621605,
|
||||
"tag": "0011_gifted_mystique",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1760504208398,
|
||||
"tag": "0012_yielding_phil_sheldon",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1760543654915,
|
||||
"tag": "0013_pink_the_order",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1760588333462,
|
||||
"tag": "0014_lowly_gateway",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1760589195551,
|
||||
"tag": "0015_exotic_loners",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1760590495868,
|
||||
"tag": "0016_majestic_roulette",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1760599350154,
|
||||
"tag": "0017_calm_zarda",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1760996053371,
|
||||
"tag": "0018_numerous_stardust",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1761112236540,
|
||||
"tag": "0019_acoustic_ben_grimm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1761719248881,
|
||||
"tag": "0020_slim_spacker_dave",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1762190473418,
|
||||
"tag": "0021_fair_ser_duncan",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "7",
|
||||
"when": 1762191156317,
|
||||
"tag": "0022_shiny_mother_askani",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1762200550705,
|
||||
"tag": "0023_cynical_jack_power",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 24,
|
||||
"version": "7",
|
||||
"when": 1763276901482,
|
||||
"tag": "0024_faithful_mesmero",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 25,
|
||||
"version": "7",
|
||||
"when": 1763277000000,
|
||||
"tag": "0025_rename_participant_ev_season_to_sports_season",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 26,
|
||||
"version": "7",
|
||||
"when": 1763278000000,
|
||||
"tag": "0026_add_source_odds_to_participant_ev",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 27,
|
||||
"version": "7",
|
||||
"when": 1763279000000,
|
||||
"tag": "0027_add_projected_points_to_team_standings",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 28,
|
||||
"version": "7",
|
||||
"when": 1763280000000,
|
||||
"tag": "0028_add_tournament_groups",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "7",
|
||||
"when": 1763281000000,
|
||||
"tag": "0029_change_participant_ev_to_decimal",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 30,
|
||||
"version": "7",
|
||||
"when": 1763282000000,
|
||||
"tag": "0030_add_draft_picks_unique_slot",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 31,
|
||||
"version": "7",
|
||||
"when": 1763283000000,
|
||||
"tag": "0031_add_autodraft_queue_only",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 32,
|
||||
"version": "7",
|
||||
"when": 1763284000000,
|
||||
"tag": "0032_consolidate_playoff_pattern",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 33,
|
||||
"version": "7",
|
||||
"when": 1763285000000,
|
||||
"tag": "0033_add_schedule_event_type",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 34,
|
||||
"version": "7",
|
||||
"when": 1763286000000,
|
||||
"tag": "0034_add_ev_snapshots",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 35,
|
||||
"version": "7",
|
||||
"when": 1763287000000,
|
||||
"tag": "0035_increase_prob_precision",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 36,
|
||||
"version": "7",
|
||||
"when": 1763288000000,
|
||||
"tag": "0036_increase_ev_precision",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 37,
|
||||
"version": "7",
|
||||
"when": 1763289000000,
|
||||
"tag": "0037_add_simulator_type_to_sports",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 38,
|
||||
"version": "7",
|
||||
"when": 1773122866361,
|
||||
"tag": "0038_sad_wilson_fisk",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 39,
|
||||
"version": "7",
|
||||
"when": 1773200000000,
|
||||
"tag": "0039_add_ucl_bracket_simulator_type",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 40,
|
||||
"version": "7",
|
||||
"when": 1773262080928,
|
||||
"tag": "0040_fat_puma",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 41,
|
||||
"version": "7",
|
||||
"when": 1773557500336,
|
||||
"tag": "0041_great_magma",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 42,
|
||||
"version": "7",
|
||||
"when": 1773600000000,
|
||||
"tag": "0042_migrate_event_date_to_starts_at",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 43,
|
||||
"version": "7",
|
||||
"when": 1773634394633,
|
||||
"tag": "0043_demonic_vanisher",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 44,
|
||||
"version": "7",
|
||||
"when": 1773700000000,
|
||||
"tag": "0044_add_ncaam_bracket_simulator_type",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 45,
|
||||
"version": "7",
|
||||
"when": 1773710000000,
|
||||
"tag": "0045_add_ncaaw_bracket_simulator_type",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 46,
|
||||
"version": "7",
|
||||
"when": 1773720000000,
|
||||
"tag": "0046_add_nba_bracket_simulator_type",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 47,
|
||||
"version": "7",
|
||||
"when": 1773730000000,
|
||||
"tag": "0047_fix_nba_bracket_simulator_type",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 48,
|
||||
"version": "7",
|
||||
"when": 1773765768905,
|
||||
"tag": "0048_eager_songbird",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 49,
|
||||
"version": "7",
|
||||
"when": 1773800000000,
|
||||
"tag": "0049_add_admin_picked_by_type",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 50,
|
||||
"version": "7",
|
||||
"when": 1773821310661,
|
||||
"tag": "0050_chief_peter_quill",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 51,
|
||||
"version": "7",
|
||||
"when": 1773900000000,
|
||||
"tag": "0051_add_standings_snapshot_unique_index",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 52,
|
||||
"version": "7",
|
||||
"when": 1773897678653,
|
||||
"tag": "0052_new_warbird",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 53,
|
||||
"version": "7",
|
||||
"when": 1773903201189,
|
||||
"tag": "0053_smooth_kingpin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 54,
|
||||
"version": "7",
|
||||
"when": 1774070769112,
|
||||
"tag": "0054_massive_vulcan",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 55,
|
||||
"version": "7",
|
||||
"when": 1774072156237,
|
||||
"tag": "0055_special_vampiro",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 56,
|
||||
"version": "7",
|
||||
"when": 1774167142673,
|
||||
"tag": "0056_jittery_the_fallen",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 57,
|
||||
"version": "7",
|
||||
"when": 1774239259550,
|
||||
"tag": "0057_cultured_doctor_doom",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 58,
|
||||
"version": "7",
|
||||
"when": 1774310000000,
|
||||
"tag": "0058_add_unique_participant_ev",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 59,
|
||||
"version": "7",
|
||||
"when": 1774329951326,
|
||||
"tag": "0059_mysterious_jack_murdock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 60,
|
||||
"version": "7",
|
||||
"when": 1774330910098,
|
||||
"tag": "0060_omniscient_outlaw_kid",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 61,
|
||||
"version": "7",
|
||||
"when": 1774410244601,
|
||||
"tag": "0061_violet_mephistopheles",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 62,
|
||||
"version": "7",
|
||||
"when": 1774500000000,
|
||||
"tag": "0062_add_mlb_bracket_simulator_type",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 63,
|
||||
"version": "7",
|
||||
"when": 1774504559479,
|
||||
"tag": "0063_bored_ultimo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 64,
|
||||
"version": "7",
|
||||
"when": 1774598151282,
|
||||
"tag": "0064_fuzzy_wolfsbane",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 65,
|
||||
"version": "7",
|
||||
"when": 1774719902130,
|
||||
"tag": "0065_kind_mantis",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 66,
|
||||
"version": "7",
|
||||
"when": 1774802533652,
|
||||
"tag": "0066_stiff_siren",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 67,
|
||||
"version": "7",
|
||||
"when": 1774900000000,
|
||||
"tag": "0067_darts_bracket",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 68,
|
||||
"version": "7",
|
||||
"when": 1775485575205,
|
||||
"tag": "0068_cs2_major_simulator",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 69,
|
||||
"version": "7",
|
||||
"when": 1775485599229,
|
||||
"tag": "0069_draft_window",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 70,
|
||||
"version": "7",
|
||||
"when": 1775485630297,
|
||||
"tag": "0070_verify_migrations",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 71,
|
||||
"version": "7",
|
||||
"when": 1775528386356,
|
||||
"tag": "0071_many_nico_minoru",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 72,
|
||||
"version": "7",
|
||||
"when": 1775652883157,
|
||||
"tag": "0072_jittery_steve_rogers",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 73,
|
||||
"version": "7",
|
||||
"when": 1775679862606,
|
||||
"tag": "0073_blushing_lady_vermin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 74,
|
||||
"version": "7",
|
||||
"when": 1775699707816,
|
||||
"tag": "0074_clammy_tiger_shark",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 75,
|
||||
"version": "7",
|
||||
"when": 1776101579459,
|
||||
"tag": "0075_chubby_stephen_strange",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 76,
|
||||
"version": "7",
|
||||
"when": 1776226761564,
|
||||
"tag": "0076_heavy_zemo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 77,
|
||||
"version": "7",
|
||||
"when": 1776312815334,
|
||||
"tag": "0077_lively_wolfpack",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 78,
|
||||
"version": "7",
|
||||
"when": 1776314387095,
|
||||
"tag": "0078_striped_rick_jones",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 79,
|
||||
"version": "7",
|
||||
"when": 1776316296907,
|
||||
"tag": "0079_wide_trauma",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 80,
|
||||
"version": "7",
|
||||
"when": 1776318182740,
|
||||
"tag": "0080_chemical_gorilla_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 81,
|
||||
"version": "7",
|
||||
"when": 1777067050669,
|
||||
"tag": "0081_careless_magdalene",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 82,
|
||||
"version": "7",
|
||||
"when": 1777090754037,
|
||||
"tag": "0082_stale_pride",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 83,
|
||||
"version": "7",
|
||||
"when": 1777180952144,
|
||||
"tag": "0083_fearless_leader",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 84,
|
||||
"version": "7",
|
||||
"when": 1777477826949,
|
||||
"tag": "0084_wooden_retro_girl",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 85,
|
||||
"version": "7",
|
||||
"when": 1777483407743,
|
||||
"tag": "0085_young_cobalt_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 86,
|
||||
"version": "7",
|
||||
"when": 1777505810235,
|
||||
"tag": "0086_marvelous_infant_terrible",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 87,
|
||||
"version": "7",
|
||||
"when": 1777661299612,
|
||||
"tag": "0087_small_susan_delgado",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 88,
|
||||
"version": "7",
|
||||
"when": 1777666830394,
|
||||
"tag": "0088_cheerful_norrin_radd",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 89,
|
||||
"version": "7",
|
||||
"when": 1777675084016,
|
||||
"tag": "0089_lonely_tigra",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 90,
|
||||
"version": "7",
|
||||
"when": 1777677410094,
|
||||
"tag": "0090_powerful_redwing",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 91,
|
||||
"version": "7",
|
||||
"when": 1777700829535,
|
||||
"tag": "0091_fast_inhumans",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 92,
|
||||
"version": "7",
|
||||
"when": 1777745229462,
|
||||
"tag": "0092_robust_klaw",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 93,
|
||||
"version": "7",
|
||||
"when": 1777783789103,
|
||||
"tag": "0093_ambiguous_hellcat",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 94,
|
||||
"version": "7",
|
||||
"when": 1777920441672,
|
||||
"tag": "0094_curved_nick_fury",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 95,
|
||||
"version": "7",
|
||||
"when": 1778043585280,
|
||||
"tag": "0095_lucky_madrox",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 96,
|
||||
"version": "7",
|
||||
"when": 1778177368941,
|
||||
"tag": "0096_demonic_vulture",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 97,
|
||||
"version": "7",
|
||||
"when": 1778189247519,
|
||||
"tag": "0097_puzzling_spectrum",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 98,
|
||||
"version": "7",
|
||||
"when": 1778261539567,
|
||||
"tag": "0098_lucky_sabra",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 99,
|
||||
"version": "7",
|
||||
"when": 1778341807236,
|
||||
"tag": "0099_peaceful_hannibal_king",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 100,
|
||||
"version": "7",
|
||||
"when": 1778440988519,
|
||||
"tag": "0100_mighty_weapon_omega",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 101,
|
||||
"version": "7",
|
||||
"when": 1778445560663,
|
||||
"tag": "0101_simple_black_bolt",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 102,
|
||||
"version": "7",
|
||||
"when": 1778539362096,
|
||||
"tag": "0102_wild_wiccan",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 103,
|
||||
"version": "7",
|
||||
"when": 1778611567910,
|
||||
"tag": "0103_salty_runaways",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 104,
|
||||
"version": "7",
|
||||
"when": 1778784280606,
|
||||
"tag": "0104_chief_boom_boom",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 105,
|
||||
"version": "7",
|
||||
"when": 1778862483243,
|
||||
"tag": "0105_mighty_talisman",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 106,
|
||||
"version": "7",
|
||||
"when": 1778867381171,
|
||||
"tag": "0106_chilly_chimera",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 107,
|
||||
"version": "7",
|
||||
"when": 1778868190284,
|
||||
"tag": "0107_outgoing_mikhail_rasputin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 108,
|
||||
"version": "7",
|
||||
"when": 1778997704083,
|
||||
"tag": "0108_faulty_purple_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 109,
|
||||
"version": "7",
|
||||
"when": 1779211722613,
|
||||
"tag": "0109_fair_peter_quill",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 110,
|
||||
"version": "7",
|
||||
"when": 1779256349467,
|
||||
"tag": "0110_thankful_nova",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 111,
|
||||
"version": "7",
|
||||
"when": 1779312132517,
|
||||
"tag": "0111_gorgeous_captain_universe",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 112,
|
||||
"version": "7",
|
||||
"when": 1779318579169,
|
||||
"tag": "0112_married_captain_midlands",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 113,
|
||||
"version": "7",
|
||||
"when": 1779579877169,
|
||||
"tag": "0113_bent_banshee",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 114,
|
||||
"version": "7",
|
||||
"when": 1780595525976,
|
||||
"tag": "0114_warm_swarm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 115,
|
||||
"version": "7",
|
||||
"when": 1780596702982,
|
||||
"tag": "0115_spooky_spot",
|
||||
"breakpoints": true
|
||||
}
|
||||
{ "idx": 0, "version": "7", "when": 1732076135211, "tag": "0000_short_donald_blake", "breakpoints": true },
|
||||
{ "idx": 1, "version": "7", "when": 1760165302772, "tag": "0001_serious_la_nuit", "breakpoints": true },
|
||||
{ "idx": 2, "version": "7", "when": 1760166775427, "tag": "0002_tired_iron_man", "breakpoints": true },
|
||||
{ "idx": 3, "version": "7", "when": 1760167848365, "tag": "0003_public_shadowcat", "breakpoints": true },
|
||||
{ "idx": 4, "version": "7", "when": 1760168383812, "tag": "0004_spicy_zarda", "breakpoints": true },
|
||||
{ "idx": 5, "version": "7", "when": 1760169537260, "tag": "0005_abnormal_thunderbird", "breakpoints": true },
|
||||
{ "idx": 6, "version": "7", "when": 1760241924414, "tag": "0006_needy_junta", "breakpoints": true },
|
||||
{ "idx": 7, "version": "7", "when": 1760327817376, "tag": "0007_pink_nebula", "breakpoints": true },
|
||||
{ "idx": 8, "version": "7", "when": 1760375514773, "tag": "0008_salty_trauma", "breakpoints": true },
|
||||
{ "idx": 9, "version": "7", "when": 1760392647508, "tag": "0009_fuzzy_korvac", "breakpoints": true },
|
||||
{ "idx": 10, "version": "7", "when": 1760469105799, "tag": "0010_complex_the_stranger", "breakpoints": true },
|
||||
{ "idx": 11, "version": "7", "when": 1760501621605, "tag": "0011_gifted_mystique", "breakpoints": true },
|
||||
{ "idx": 12, "version": "7", "when": 1760504208398, "tag": "0012_yielding_phil_sheldon", "breakpoints": true },
|
||||
{ "idx": 13, "version": "7", "when": 1760543654915, "tag": "0013_pink_the_order", "breakpoints": true },
|
||||
{ "idx": 14, "version": "7", "when": 1760588333462, "tag": "0014_lowly_gateway", "breakpoints": true },
|
||||
{ "idx": 15, "version": "7", "when": 1760589195551, "tag": "0015_exotic_loners", "breakpoints": true },
|
||||
{ "idx": 16, "version": "7", "when": 1760590495868, "tag": "0016_majestic_roulette", "breakpoints": true },
|
||||
{ "idx": 17, "version": "7", "when": 1760599350154, "tag": "0017_calm_zarda", "breakpoints": true },
|
||||
{ "idx": 18, "version": "7", "when": 1760996053371, "tag": "0018_numerous_stardust", "breakpoints": true },
|
||||
{ "idx": 19, "version": "7", "when": 1761112236540, "tag": "0019_acoustic_ben_grimm", "breakpoints": true },
|
||||
{ "idx": 20, "version": "7", "when": 1761719248881, "tag": "0020_slim_spacker_dave", "breakpoints": true },
|
||||
{ "idx": 21, "version": "7", "when": 1762190473418, "tag": "0021_fair_ser_duncan", "breakpoints": true },
|
||||
{ "idx": 22, "version": "7", "when": 1762191156317, "tag": "0022_shiny_mother_askani", "breakpoints": true },
|
||||
{ "idx": 23, "version": "7", "when": 1762200550705, "tag": "0023_cynical_jack_power", "breakpoints": true },
|
||||
{ "idx": 24, "version": "7", "when": 1763276901482, "tag": "0024_faithful_mesmero", "breakpoints": true },
|
||||
{ "idx": 25, "version": "7", "when": 1763277000000, "tag": "0025_rename_participant_ev_season_to_sports_season", "breakpoints": true },
|
||||
{ "idx": 26, "version": "7", "when": 1763278000000, "tag": "0026_add_source_odds_to_participant_ev", "breakpoints": true },
|
||||
{ "idx": 27, "version": "7", "when": 1763279000000, "tag": "0027_add_projected_points_to_team_standings", "breakpoints": true },
|
||||
{ "idx": 28, "version": "7", "when": 1763280000000, "tag": "0028_add_tournament_groups", "breakpoints": true },
|
||||
{ "idx": 29, "version": "7", "when": 1763281000000, "tag": "0029_change_participant_ev_to_decimal", "breakpoints": true },
|
||||
{ "idx": 30, "version": "7", "when": 1763282000000, "tag": "0030_add_draft_picks_unique_slot", "breakpoints": true },
|
||||
{ "idx": 31, "version": "7", "when": 1763283000000, "tag": "0031_add_autodraft_queue_only", "breakpoints": true },
|
||||
{ "idx": 32, "version": "7", "when": 1763284000000, "tag": "0032_consolidate_playoff_pattern", "breakpoints": true },
|
||||
{ "idx": 33, "version": "7", "when": 1763285000000, "tag": "0033_add_schedule_event_type", "breakpoints": true },
|
||||
{ "idx": 34, "version": "7", "when": 1763286000000, "tag": "0034_add_ev_snapshots", "breakpoints": true },
|
||||
{ "idx": 35, "version": "7", "when": 1763287000000, "tag": "0035_increase_prob_precision", "breakpoints": true },
|
||||
{ "idx": 36, "version": "7", "when": 1763288000000, "tag": "0036_increase_ev_precision", "breakpoints": true },
|
||||
{ "idx": 37, "version": "7", "when": 1763289000000, "tag": "0037_add_simulator_type_to_sports", "breakpoints": true },
|
||||
{ "idx": 38, "version": "7", "when": 1773122866361, "tag": "0038_sad_wilson_fisk", "breakpoints": true },
|
||||
{ "idx": 39, "version": "7", "when": 1773200000000, "tag": "0039_add_ucl_bracket_simulator_type", "breakpoints": true },
|
||||
{ "idx": 40, "version": "7", "when": 1773262080928, "tag": "0040_fat_puma", "breakpoints": true },
|
||||
{ "idx": 41, "version": "7", "when": 1773557500336, "tag": "0041_great_magma", "breakpoints": true },
|
||||
{ "idx": 42, "version": "7", "when": 1773600000000, "tag": "0042_migrate_event_date_to_starts_at", "breakpoints": true },
|
||||
{ "idx": 43, "version": "7", "when": 1773634394633, "tag": "0043_demonic_vanisher", "breakpoints": true },
|
||||
{ "idx": 44, "version": "7", "when": 1773700000000, "tag": "0044_add_ncaam_bracket_simulator_type", "breakpoints": true },
|
||||
{ "idx": 45, "version": "7", "when": 1773710000000, "tag": "0045_add_ncaaw_bracket_simulator_type", "breakpoints": true },
|
||||
{ "idx": 46, "version": "7", "when": 1773720000000, "tag": "0046_add_nba_bracket_simulator_type", "breakpoints": true },
|
||||
{ "idx": 47, "version": "7", "when": 1773730000000, "tag": "0047_fix_nba_bracket_simulator_type", "breakpoints": true },
|
||||
{ "idx": 48, "version": "7", "when": 1773765768905, "tag": "0048_eager_songbird", "breakpoints": true },
|
||||
{ "idx": 49, "version": "7", "when": 1773800000000, "tag": "0049_add_admin_picked_by_type", "breakpoints": true },
|
||||
{ "idx": 50, "version": "7", "when": 1773821310661, "tag": "0050_chief_peter_quill", "breakpoints": true },
|
||||
{ "idx": 51, "version": "7", "when": 1773900000000, "tag": "0051_add_standings_snapshot_unique_index", "breakpoints": true },
|
||||
{ "idx": 52, "version": "7", "when": 1773897678653, "tag": "0052_new_warbird", "breakpoints": true },
|
||||
{ "idx": 53, "version": "7", "when": 1773903201189, "tag": "0053_smooth_kingpin", "breakpoints": true },
|
||||
{ "idx": 54, "version": "7", "when": 1774070769112, "tag": "0054_massive_vulcan", "breakpoints": true },
|
||||
{ "idx": 55, "version": "7", "when": 1774072156237, "tag": "0055_special_vampiro", "breakpoints": true },
|
||||
{ "idx": 56, "version": "7", "when": 1774167142673, "tag": "0056_jittery_the_fallen", "breakpoints": true },
|
||||
{ "idx": 57, "version": "7", "when": 1774239259550, "tag": "0057_cultured_doctor_doom", "breakpoints": true },
|
||||
{ "idx": 58, "version": "7", "when": 1774310000000, "tag": "0058_add_unique_participant_ev", "breakpoints": true },
|
||||
{ "idx": 59, "version": "7", "when": 1774329951326, "tag": "0059_mysterious_jack_murdock", "breakpoints": true },
|
||||
{ "idx": 60, "version": "7", "when": 1774330910098, "tag": "0060_omniscient_outlaw_kid", "breakpoints": true },
|
||||
{ "idx": 61, "version": "7", "when": 1774410244601, "tag": "0061_violet_mephistopheles", "breakpoints": true },
|
||||
{ "idx": 62, "version": "7", "when": 1774500000000, "tag": "0062_add_mlb_bracket_simulator_type", "breakpoints": true },
|
||||
{ "idx": 63, "version": "7", "when": 1774504559479, "tag": "0063_bored_ultimo", "breakpoints": true },
|
||||
{ "idx": 64, "version": "7", "when": 1774598151282, "tag": "0064_fuzzy_wolfsbane", "breakpoints": true },
|
||||
{ "idx": 65, "version": "7", "when": 1774719902130, "tag": "0065_kind_mantis", "breakpoints": true },
|
||||
{ "idx": 66, "version": "7", "when": 1774802533652, "tag": "0066_stiff_siren", "breakpoints": true },
|
||||
{ "idx": 67, "version": "7", "when": 1774900000000, "tag": "0067_darts_bracket", "breakpoints": true },
|
||||
{ "idx": 68, "version": "7", "when": 1775485575205, "tag": "0068_cs2_major_simulator", "breakpoints": true },
|
||||
{ "idx": 69, "version": "7", "when": 1775485599229, "tag": "0069_draft_window", "breakpoints": true },
|
||||
{ "idx": 70, "version": "7", "when": 1775485630297, "tag": "0070_verify_migrations", "breakpoints": true },
|
||||
{ "idx": 71, "version": "7", "when": 1775528386356, "tag": "0071_many_nico_minoru", "breakpoints": true },
|
||||
{ "idx": 72, "version": "7", "when": 1775652883157, "tag": "0072_jittery_steve_rogers", "breakpoints": true },
|
||||
{ "idx": 73, "version": "7", "when": 1775679862606, "tag": "0073_blushing_lady_vermin", "breakpoints": true },
|
||||
{ "idx": 74, "version": "7", "when": 1775699707816, "tag": "0074_clammy_tiger_shark", "breakpoints": true },
|
||||
{ "idx": 75, "version": "7", "when": 1776101579459, "tag": "0075_chubby_stephen_strange", "breakpoints": true },
|
||||
{ "idx": 76, "version": "7", "when": 1776226761564, "tag": "0076_heavy_zemo", "breakpoints": true },
|
||||
{ "idx": 77, "version": "7", "when": 1776312815334, "tag": "0077_lively_wolfpack", "breakpoints": true },
|
||||
{ "idx": 78, "version": "7", "when": 1776314387095, "tag": "0078_striped_rick_jones", "breakpoints": true },
|
||||
{ "idx": 79, "version": "7", "when": 1776316296907, "tag": "0079_wide_trauma", "breakpoints": true },
|
||||
{ "idx": 80, "version": "7", "when": 1776318182740, "tag": "0080_chemical_gorilla_man", "breakpoints": true },
|
||||
{ "idx": 81, "version": "7", "when": 1777067050669, "tag": "0081_careless_magdalene", "breakpoints": true },
|
||||
{ "idx": 82, "version": "7", "when": 1777090754037, "tag": "0082_stale_pride", "breakpoints": true },
|
||||
{ "idx": 83, "version": "7", "when": 1777180952144, "tag": "0083_fearless_leader", "breakpoints": true },
|
||||
{ "idx": 84, "version": "7", "when": 1777477826949, "tag": "0084_wooden_retro_girl", "breakpoints": true },
|
||||
{ "idx": 85, "version": "7", "when": 1777483407743, "tag": "0085_young_cobalt_man", "breakpoints": true },
|
||||
{ "idx": 86, "version": "7", "when": 1777505810235, "tag": "0086_marvelous_infant_terrible", "breakpoints": true },
|
||||
{ "idx": 87, "version": "7", "when": 1777661299612, "tag": "0087_small_susan_delgado", "breakpoints": true },
|
||||
{ "idx": 88, "version": "7", "when": 1777666830394, "tag": "0088_cheerful_norrin_radd", "breakpoints": true },
|
||||
{ "idx": 89, "version": "7", "when": 1777675084016, "tag": "0089_lonely_tigra", "breakpoints": true },
|
||||
{ "idx": 90, "version": "7", "when": 1777677410094, "tag": "0090_powerful_redwing", "breakpoints": true },
|
||||
{ "idx": 91, "version": "7", "when": 1777700829535, "tag": "0091_fast_inhumans", "breakpoints": true },
|
||||
{ "idx": 92, "version": "7", "when": 1777745229462, "tag": "0092_robust_klaw", "breakpoints": true },
|
||||
{ "idx": 93, "version": "7", "when": 1777783789103, "tag": "0093_ambiguous_hellcat", "breakpoints": true },
|
||||
{ "idx": 94, "version": "7", "when": 1777920441672, "tag": "0094_curved_nick_fury", "breakpoints": true },
|
||||
{ "idx": 95, "version": "7", "when": 1778043585280, "tag": "0095_lucky_madrox", "breakpoints": true },
|
||||
{ "idx": 96, "version": "7", "when": 1778177368941, "tag": "0096_demonic_vulture", "breakpoints": true },
|
||||
{ "idx": 97, "version": "7", "when": 1778189247519, "tag": "0097_puzzling_spectrum", "breakpoints": true },
|
||||
{ "idx": 98, "version": "7", "when": 1778261539567, "tag": "0098_lucky_sabra", "breakpoints": true },
|
||||
{ "idx": 99, "version": "7", "when": 1778341807236, "tag": "0099_peaceful_hannibal_king", "breakpoints": true },
|
||||
{ "idx": 100, "version": "7", "when": 1778440988519, "tag": "0100_mighty_weapon_omega", "breakpoints": true },
|
||||
{ "idx": 101, "version": "7", "when": 1778445560663, "tag": "0101_simple_black_bolt", "breakpoints": true },
|
||||
{ "idx": 102, "version": "7", "when": 1778539362096, "tag": "0102_wild_wiccan", "breakpoints": true },
|
||||
{ "idx": 103, "version": "7", "when": 1778611567910, "tag": "0103_salty_runaways", "breakpoints": true },
|
||||
{ "idx": 104, "version": "7", "when": 1778784280606, "tag": "0104_chief_boom_boom", "breakpoints": true },
|
||||
{ "idx": 105, "version": "7", "when": 1778862483243, "tag": "0105_mighty_talisman", "breakpoints": true },
|
||||
{ "idx": 106, "version": "7", "when": 1778867381171, "tag": "0106_chilly_chimera", "breakpoints": true },
|
||||
{ "idx": 107, "version": "7", "when": 1778868190284, "tag": "0107_outgoing_mikhail_rasputin", "breakpoints": true },
|
||||
{ "idx": 108, "version": "7", "when": 1778997704083, "tag": "0108_faulty_purple_man", "breakpoints": true },
|
||||
{ "idx": 109, "version": "7", "when": 1779211722613, "tag": "0109_fair_peter_quill", "breakpoints": true },
|
||||
{ "idx": 110, "version": "7", "when": 1779256349467, "tag": "0110_thankful_nova", "breakpoints": true },
|
||||
{ "idx": 111, "version": "7", "when": 1779312132517, "tag": "0111_gorgeous_captain_universe", "breakpoints": true },
|
||||
{ "idx": 112, "version": "7", "when": 1779318579169, "tag": "0112_married_captain_midlands", "breakpoints": true },
|
||||
{ "idx": 113, "version": "7", "when": 1779579877169, "tag": "0113_bent_banshee", "breakpoints": true },
|
||||
{ "idx": 114, "version": "7", "when": 1780595525976, "tag": "0114_warm_swarm", "breakpoints": true },
|
||||
{ "idx": 115, "version": "7", "when": 1780596702982, "tag": "0115_spooky_spot", "breakpoints": true },
|
||||
{ "idx": 116, "version": "7", "when": 1780454064143, "tag": "0116_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);
|
||||
});
|
||||
|
||||
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 teamId = 'team-456';
|
||||
mockSocketIO.to(`draft-${seasonId}`).emit('timer-update', {
|
||||
seasonId, teamId, timeRemaining: 30, currentPickNumber: 1,
|
||||
});
|
||||
expect(mockSocketIO.emit).toHaveBeenCalledWith('timer-update', {
|
||||
seasonId, teamId, timeRemaining: 30, currentPickNumber: 1,
|
||||
mockSocketIO.to(`draft-${seasonId}`).emit('timer-pick-started', {
|
||||
seasonId, teamId, pickNumber: 1, expiresAt: Date.now() + 30000, timeRemaining: 30,
|
||||
});
|
||||
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 * as schema from "~/database/schema";
|
||||
import { eq, and, asc } from "drizzle-orm";
|
||||
import { checkOvernightPause } from "./overnight-pause-check";
|
||||
import { calculatePickInfo } from "~/models/draft-utils";
|
||||
import { logger } from "./logger";
|
||||
import { db } from "./db";
|
||||
|
||||
|
|
@ -18,11 +20,18 @@ interface ServerToClientEvents {
|
|||
"draft-started": (data: { seasonId: string; currentPickNumber: number }) => void;
|
||||
"draft-completed": () => void;
|
||||
"draft-room-closed": () => void;
|
||||
"timer-update": (data: {
|
||||
"timer-bank-updated": (data: { teamId: string; timeRemaining: number }) => void;
|
||||
"timer-pick-started": (data: {
|
||||
seasonId: string;
|
||||
teamId: string;
|
||||
timeRemaining: number;
|
||||
currentPickNumber: number;
|
||||
pickNumber: number;
|
||||
expiresAt: number; // ms timestamp
|
||||
timeRemaining: number; // seconds remaining at emit time
|
||||
}) => void;
|
||||
"timer-overnight-paused": (data: {
|
||||
seasonId: string;
|
||||
teamId: string;
|
||||
resumesAtUTC?: number;
|
||||
}) => void;
|
||||
"autodraft-updated": (data: {
|
||||
teamId: string;
|
||||
|
|
@ -47,6 +56,8 @@ interface ServerToClientEvents {
|
|||
currentPickNumber: number;
|
||||
isPaused: boolean;
|
||||
status: string;
|
||||
isOvernightPause: boolean;
|
||||
overnightResumesAt?: number;
|
||||
picks: Array<{
|
||||
id: string;
|
||||
pickNumber: number;
|
||||
|
|
@ -60,6 +71,7 @@ interface ServerToClientEvents {
|
|||
timers: Array<{
|
||||
teamId: string;
|
||||
timeRemaining: number;
|
||||
expiresAt?: number; // ms timestamp; present for the currently-active team
|
||||
}>;
|
||||
queue?: Array<{
|
||||
id: string;
|
||||
|
|
@ -224,7 +236,7 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
|
|||
// tokens or flaky mobile networks. This socket-based sync provides an
|
||||
// additional, more reliable path since the socket is already connected.
|
||||
try {
|
||||
const [seasonData, picks, timerRows, queueItems, watchlistItems] = await Promise.all([
|
||||
const [seasonData, picks, timerRows, draftSlots, queueItems, watchlistItems] = await Promise.all([
|
||||
db.query.seasons.findFirst({
|
||||
where: eq(schema.seasons.id, seasonId),
|
||||
}),
|
||||
|
|
@ -258,6 +270,10 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
|
|||
db.query.draftTimers.findMany({
|
||||
where: eq(schema.draftTimers.seasonId, seasonId),
|
||||
}),
|
||||
db.query.draftSlots.findMany({
|
||||
where: eq(schema.draftSlots.seasonId, seasonId),
|
||||
orderBy: asc(schema.draftSlots.draftOrder),
|
||||
}),
|
||||
teamId
|
||||
? db.query.draftQueue.findMany({
|
||||
where: and(
|
||||
|
|
@ -278,15 +294,47 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
|
|||
]);
|
||||
|
||||
if (seasonData) {
|
||||
// Compute overnight-pause state so reconnecting clients see it immediately.
|
||||
let isOvernightPause = false;
|
||||
let overnightResumesAt: number | undefined;
|
||||
|
||||
if (
|
||||
draftSlots.length > 0 &&
|
||||
seasonData.status === "draft" &&
|
||||
!seasonData.draftPaused
|
||||
) {
|
||||
const { pickInRound } = calculatePickInfo(
|
||||
seasonData.currentPickNumber ?? 1,
|
||||
draftSlots.length
|
||||
);
|
||||
const currentSlot = draftSlots.find((s) => s.draftOrder === pickInRound);
|
||||
if (currentSlot) {
|
||||
const result = await checkOvernightPause(seasonData, currentSlot.teamId);
|
||||
isOvernightPause = result.active;
|
||||
overnightResumesAt = result.resumesAtUTC;
|
||||
}
|
||||
}
|
||||
|
||||
socket.emit("draft-state-sync", {
|
||||
currentPickNumber: seasonData.currentPickNumber || 1,
|
||||
isPaused: seasonData.draftPaused || false,
|
||||
status: seasonData.status,
|
||||
isOvernightPause,
|
||||
overnightResumesAt,
|
||||
picks,
|
||||
timers: timerRows.map((t) => ({
|
||||
teamId: t.teamId,
|
||||
timeRemaining: t.timeRemaining,
|
||||
})),
|
||||
timers: timerRows.map((t) => {
|
||||
const nowMs = Date.now();
|
||||
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,
|
||||
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 { 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 { getSocketIO } from "./socket";
|
||||
import { executeAutoPick, calculatePickInfo } from "~/models/draft-utils";
|
||||
import { isInOvernightWindow, getOvernightResumeUTC } from "~/lib/overnight-pause";
|
||||
import { checkOvernightPause, evictOvernightPauseCache, overnightPauseCacheKeys, clearAllOvernightPauseCaches } from "./overnight-pause-check";
|
||||
import { logger } from "./logger";
|
||||
import { db } from "./db";
|
||||
import { startDraft } from "~/services/draft-autostart";
|
||||
|
||||
let timerInterval: NodeJS.Timeout | null = null;
|
||||
let timerTickRunning = false;
|
||||
// Per-season in-memory state
|
||||
const pickTimeouts = new Map<string, NodeJS.Timeout>(); // seasonId → active setTimeout
|
||||
const overnightResumeTimeouts = new Map<string, NodeJS.Timeout>(); // seasonId → resume setTimeout
|
||||
const schedulingInProgress = new Set<string>(); // prevents concurrent scheduling
|
||||
|
||||
// Draft slots never change during an active draft — cache them to avoid
|
||||
// a redundant query on every tick.
|
||||
// Draft slots never change during an active draft — cache them to avoid a redundant query.
|
||||
const draftSlotsCache = new Map<string, { teamId: string; draftOrder: number }[]>();
|
||||
|
||||
// Team timezone cache for per_user overnight pause mode (seasonId → teamId → timezone).
|
||||
// Populated lazily; evicted when the season leaves active drafting.
|
||||
const teamTimezoneCache = new Map<string, Map<string, string | null>>();
|
||||
let recoveryInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
async function getTeamTimezone(seasonId: string, teamId: string): Promise<string | null> {
|
||||
let seasonMap = teamTimezoneCache.get(seasonId);
|
||||
if (!seasonMap) {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({ teamId: schema.teams.id, timezone: schema.users.timezone })
|
||||
.from(schema.teams)
|
||||
.leftJoin(schema.users, sql`${schema.teams.ownerId}::uuid = ${schema.users.id}`)
|
||||
.where(eq(schema.teams.seasonId, seasonId));
|
||||
seasonMap = new Map(rows.map((r) => [r.teamId, r.timezone || null]));
|
||||
} catch (err) {
|
||||
// ownerId column is varchar; if any row still holds a non-UUID value
|
||||
// (pre-migration remnant) the ::uuid cast throws. Degrade gracefully.
|
||||
logger.error("[Timer] getTeamTimezone cast failed, skipping overnight check:", err);
|
||||
seasonMap = new Map();
|
||||
}
|
||||
teamTimezoneCache.set(seasonId, seasonMap);
|
||||
}
|
||||
return seasonMap.get(teamId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the current team's pick is in an overnight pause window.
|
||||
* Returns { active: false } or { active: true, resumesAtUTC: number }.
|
||||
*/
|
||||
async function checkOvernightPause(
|
||||
season: InferSelectModel<typeof schema.seasons>,
|
||||
currentTeamId: string
|
||||
): Promise<{ active: boolean; resumesAtUTC?: number }> {
|
||||
const mode = season.overnightPauseMode;
|
||||
const start = season.overnightPauseStart;
|
||||
const end = season.overnightPauseEnd;
|
||||
|
||||
if (mode === "none" || !start || !end) return { active: false };
|
||||
|
||||
let tz: string | null = null;
|
||||
if (mode === "league") {
|
||||
tz = season.overnightPauseTimezone || null;
|
||||
} else {
|
||||
// per_user: use the team owner's timezone, fall back to league timezone
|
||||
tz = await getTeamTimezone(season.id, currentTeamId);
|
||||
if (!tz) tz = season.overnightPauseTimezone || null;
|
||||
}
|
||||
|
||||
if (!tz) return { active: false };
|
||||
|
||||
if (isInOvernightWindow(tz, start, end)) {
|
||||
const resumesAt = getOvernightResumeUTC(tz, end);
|
||||
return { active: true, resumesAtUTC: resumesAt.getTime() };
|
||||
}
|
||||
return { active: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the draft timer system
|
||||
* Runs every second to update all active draft timers
|
||||
*/
|
||||
export function startDraftTimerSystem(): void {
|
||||
if (timerInterval) {
|
||||
logger.log("[Timer] Timer system already running");
|
||||
return;
|
||||
}
|
||||
|
||||
timerInterval = setInterval(async () => {
|
||||
// Skip this tick if the previous one is still running (e.g. long autodraft chain)
|
||||
// to prevent concurrent ticks from racing on the same pick slot.
|
||||
if (timerTickRunning) return;
|
||||
timerTickRunning = true;
|
||||
try {
|
||||
await updateDraftTimers();
|
||||
} catch (error) {
|
||||
logger.error("[Timer] Error updating draft timers:", error);
|
||||
} finally {
|
||||
timerTickRunning = false;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
logger.log("[Timer] Draft timer system started");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the draft timer system
|
||||
*/
|
||||
export function stopDraftTimerSystem(): void {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = null;
|
||||
logger.log("[Timer] Draft timer system stopped");
|
||||
async function getDraftSlotsCached(seasonId: string): Promise<{ teamId: string; draftOrder: number }[]> {
|
||||
let slots = draftSlotsCache.get(seasonId);
|
||||
if (!slots) {
|
||||
slots = await db.query.draftSlots.findMany({
|
||||
where: eq(schema.draftSlots.seasonId, seasonId),
|
||||
orderBy: asc(schema.draftSlots.draftOrder),
|
||||
});
|
||||
draftSlotsCache.set(seasonId, slots);
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
async function checkAndAutoStartDrafts(): Promise<void> {
|
||||
|
|
@ -136,8 +56,6 @@ async function checkAndAutoStartDrafts(): Promise<void> {
|
|||
logger.error(`[Timer] Auto-start failed for season ${season.id}: ${result.error}`);
|
||||
|
||||
if (result.error === "No draft slots found for this season") {
|
||||
// Disable auto-start to stop the retry loop. The commissioner will see
|
||||
// the setting is now off and can re-enable it once the order is set.
|
||||
await db
|
||||
.update(schema.seasons)
|
||||
.set({ autoStartDraft: false })
|
||||
|
|
@ -147,206 +65,32 @@ async function checkAndAutoStartDrafts(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all active draft timers
|
||||
* Called every second by the timer interval
|
||||
*/
|
||||
async function updateDraftTimers(): Promise<void> {
|
||||
await checkAndAutoStartDrafts();
|
||||
|
||||
const io = getSocketIO();
|
||||
|
||||
// Get all active drafts
|
||||
const activeDrafts = await db.query.seasons.findMany({
|
||||
where: eq(schema.seasons.status, "draft"),
|
||||
});
|
||||
|
||||
if (activeDrafts.length === 0) {
|
||||
draftSlotsCache.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// Evict cache entries for seasons no longer actively drafting.
|
||||
const activeIds = new Set(activeDrafts.map((s) => s.id));
|
||||
for (const cachedId of draftSlotsCache.keys()) {
|
||||
if (!activeIds.has(cachedId)) draftSlotsCache.delete(cachedId);
|
||||
}
|
||||
for (const cachedId of teamTimezoneCache.keys()) {
|
||||
if (!activeIds.has(cachedId)) teamTimezoneCache.delete(cachedId);
|
||||
}
|
||||
|
||||
for (const season of activeDrafts) {
|
||||
// Skip if draft is paused
|
||||
if (season.draftPaused) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentPickNumber = season.currentPickNumber ?? 1;
|
||||
|
||||
// Draft slots never change during an active draft — use the cache.
|
||||
let draftSlots = draftSlotsCache.get(season.id);
|
||||
if (!draftSlots) {
|
||||
draftSlots = await db.query.draftSlots.findMany({
|
||||
where: eq(schema.draftSlots.seasonId, season.id),
|
||||
orderBy: asc(schema.draftSlots.draftOrder),
|
||||
});
|
||||
draftSlotsCache.set(season.id, draftSlots);
|
||||
}
|
||||
|
||||
const totalTeams = draftSlots.length;
|
||||
if (totalTeams === 0) continue;
|
||||
|
||||
const { pickInRound } = calculatePickInfo(currentPickNumber, totalTeams);
|
||||
const currentDraftSlot = draftSlots.find(
|
||||
(slot) => slot.draftOrder === pickInRound
|
||||
);
|
||||
|
||||
if (!currentDraftSlot) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentTeamId = currentDraftSlot.teamId;
|
||||
|
||||
// Get current team's timer
|
||||
const timer = await db.query.draftTimers.findFirst({
|
||||
where: and(
|
||||
eq(schema.draftTimers.seasonId, season.id),
|
||||
eq(schema.draftTimers.teamId, currentTeamId)
|
||||
),
|
||||
});
|
||||
|
||||
if (!timer) {
|
||||
logger.warn(
|
||||
`[Timer] No timer found for team ${currentTeamId} in season ${season.id}, creating with initial time`
|
||||
);
|
||||
// Standard mode seeds with the per-pick time; chess clock seeds with the full bank.
|
||||
const initialTime = season.draftTimerMode === "standard"
|
||||
? (season.draftIncrementTime || 30)
|
||||
: (season.draftInitialTime || 120);
|
||||
await db
|
||||
.insert(schema.draftTimers)
|
||||
.values({
|
||||
seasonId: season.id,
|
||||
teamId: currentTeamId,
|
||||
timeRemaining: initialTime,
|
||||
});
|
||||
|
||||
// Emit timer update so clients are aware of the new timer
|
||||
io.to(`draft-${season.id}`).emit("timer-update", {
|
||||
seasonId: season.id,
|
||||
teamId: currentTeamId,
|
||||
timeRemaining: initialTime,
|
||||
currentPickNumber,
|
||||
});
|
||||
|
||||
// Continue processing with the newly created timer on the next tick
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch autodraft settings once — used both for while_on bypass and timer-expiry path.
|
||||
const autodraftSettings = await db.query.autodraftSettings.findFirst({
|
||||
where: and(
|
||||
eq(schema.autodraftSettings.seasonId, season.id),
|
||||
eq(schema.autodraftSettings.teamId, currentTeamId)
|
||||
),
|
||||
});
|
||||
const shouldAutodraft = autodraftSettings?.isEnabled ?? false;
|
||||
// while_on means "pick immediately when it's my turn" — bypass the countdown.
|
||||
const isWhileOn = shouldAutodraft && autodraftSettings?.mode === "while_on";
|
||||
|
||||
// Overnight pause: freeze timer for non-autodraft teams in their overnight window.
|
||||
const overnightPause = await checkOvernightPause(season, currentTeamId);
|
||||
const isOvernightFreeze = overnightPause.active && !shouldAutodraft;
|
||||
|
||||
// Trigger pick when: timer expired OR team is in while_on autodraft mode.
|
||||
// The chain picks consecutive while_on teams after each pick, but it is capped at
|
||||
// totalTeams iterations. The while_on bypass here fills the gap: the timer picks
|
||||
// the next while_on team within one tick (≤1 s) rather than waiting for the full
|
||||
// countdown to expire. If the chain already made this pick, executeAutoPick
|
||||
// returns "Pick already made" which triggerAutoPick treats as a non-fatal no-op.
|
||||
if (timer.timeRemaining <= 0 || isWhileOn) {
|
||||
if (timer.timeRemaining <= 0) {
|
||||
logger.log(
|
||||
`[Timer] ⚠️ Timer expired for team ${currentTeamId} in season ${season.id} (pick ${currentPickNumber})`
|
||||
);
|
||||
} else {
|
||||
logger.log(
|
||||
`[Timer] ⚡ while_on autodraft — immediate pick for team ${currentTeamId} in season ${season.id} (pick ${currentPickNumber})`
|
||||
);
|
||||
// Emit 0 so clients see the timer hit zero before the pick-made event arrives.
|
||||
io.to(`draft-${season.id}`).emit("timer-update", {
|
||||
seasonId: season.id,
|
||||
teamId: currentTeamId,
|
||||
timeRemaining: 0,
|
||||
currentPickNumber,
|
||||
overnightPauseActive: false,
|
||||
});
|
||||
}
|
||||
|
||||
const success = await triggerAutoPick(season.id, currentTeamId, currentPickNumber, shouldAutodraft ? (autodraftSettings ?? null) : null);
|
||||
|
||||
if (!success) {
|
||||
logger.error(`[Timer] Pausing draft ${season.id} — auto-pick failed for team ${currentTeamId} pick ${currentPickNumber}`);
|
||||
await db.update(schema.seasons).set({ draftPaused: true }).where(eq(schema.seasons.id, season.id));
|
||||
io.to(`draft-${season.id}`).emit("draft-paused", { seasonId: season.id, paused: true });
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Overnight freeze: skip decrement, notify clients to display the pause indicator.
|
||||
if (isOvernightFreeze) {
|
||||
io.to(`draft-${season.id}`).emit("timer-update", {
|
||||
seasonId: season.id,
|
||||
teamId: currentTeamId,
|
||||
timeRemaining: timer.timeRemaining,
|
||||
currentPickNumber,
|
||||
overnightPauseActive: true,
|
||||
resumesAtUTC: overnightPause.resumesAtUTC,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Atomically decrement timer (race-condition safe: uses DB-level update
|
||||
// so concurrent increments from pick handlers are never overwritten)
|
||||
const [updatedTimer] = await db
|
||||
.update(schema.draftTimers)
|
||||
.set({
|
||||
timeRemaining: sql`GREATEST(${schema.draftTimers.timeRemaining} - 1, 0)`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.draftTimers.id, timer.id))
|
||||
.returning();
|
||||
|
||||
const newTimeRemaining = updatedTimer?.timeRemaining ?? 0;
|
||||
|
||||
// Emit timer update to all clients in the draft room
|
||||
io.to(`draft-${season.id}`).emit("timer-update", {
|
||||
seasonId: season.id,
|
||||
teamId: currentTeamId,
|
||||
timeRemaining: newTimeRemaining,
|
||||
currentPickNumber,
|
||||
overnightPauseActive: false,
|
||||
});
|
||||
|
||||
// If timer just hit 0, trigger auto-pick (next_pick mode or no autodraft)
|
||||
if (newTimeRemaining === 0) {
|
||||
const success = await triggerAutoPick(season.id, currentTeamId, currentPickNumber, shouldAutodraft ? (autodraftSettings ?? null) : null);
|
||||
|
||||
if (!success) {
|
||||
logger.error(`[Timer] Pausing draft ${season.id} — auto-pick failed for team ${currentTeamId} pick ${currentPickNumber}`);
|
||||
await db.update(schema.seasons).set({ draftPaused: true }).where(eq(schema.seasons.id, season.id));
|
||||
io.to(`draft-${season.id}`).emit("draft-paused", { seasonId: season.id, paused: true });
|
||||
}
|
||||
}
|
||||
function cancelPickTimeout(seasonId: string): void {
|
||||
const existing = pickTimeouts.get(seasonId);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
pickTimeouts.delete(seasonId);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelOvernightResumeTimeout(seasonId: string): void {
|
||||
const existing = overnightResumeTimeouts.get(seasonId);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
overnightResumeTimeouts.delete(seasonId);
|
||||
}
|
||||
}
|
||||
|
||||
async function pauseDraftOnError(seasonId: string, teamId: string): Promise<void> {
|
||||
logger.error(`[Timer] Pausing draft ${seasonId} — pick failed for team ${teamId}`);
|
||||
await db.update(schema.seasons).set({ draftPaused: true }).where(eq(schema.seasons.id, seasonId));
|
||||
try {
|
||||
getSocketIO().to(`draft-${seasonId}`).emit("draft-paused", { seasonId, paused: true });
|
||||
} catch (err) {
|
||||
logger.error("[Timer] Failed to emit draft-paused:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an automatic pick when timer expires.
|
||||
* Returns true if the pick succeeded (or was already made by another path),
|
||||
* false if a real failure occurred that requires commissioner intervention.
|
||||
*/
|
||||
async function triggerAutoPick(
|
||||
seasonId: string,
|
||||
teamId: string,
|
||||
|
|
@ -364,10 +108,7 @@ async function triggerAutoPick(
|
|||
});
|
||||
|
||||
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}`);
|
||||
return false;
|
||||
}
|
||||
|
|
@ -378,3 +119,331 @@ async function triggerAutoPick(
|
|||
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