From c175d70084074581232f6c82e61d29895c4fe143 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 03:04:46 +0000 Subject: [PATCH 1/2] Phase 1: Replace per-second timer tick with event-driven scheduler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates the setInterval(1s) + per-second DB write by storing picksExpiresAt in draft_timers and using a targeted setTimeout per pick. Clients count down locally from the expiresAt timestamp, removing server-pushed timer-update events. Key changes: - database/schema.ts: add picksExpiresAt and picksStartedAt to draft_timers - server/timer.ts: full rewrite — schedulePickForSeason, rescheduleTimer, 30s recovery interval instead of 1s tick, overnight-pause resume scheduling - server/socket.ts: new timer-pick-started / timer-overnight-paused events, updated draft-state-sync to include expiresAt for reconnect recovery - draft.make-pick / draft.force-manual-pick: compute actual remaining from picksExpiresAt at pick time; call rescheduleTimer after the autodraft chain - useDraftSocketEvents: handle new timer events, restore countdown on reconnect - $leagueId.draft.$seasonId: client-side countdown useEffect from expiresAt - plans/zero-downtime-scaling.md: full 4-phase scaling plan for future reference Resolves 2351 unit tests (all passing). https://claude.ai/code/session_019k5J6Ty7uP5HxSx6CsbiBK --- .../docker-compose-production.yml | 43 + app/hooks/useDraftSocketEvents.ts | 45 +- .../__tests__/draft.force-manual-pick.test.ts | 45 +- ...draft.force-manual-pick.timer-mode.test.ts | 101 +- .../draft.make-pick.timer-mode.test.ts | 154 ++- app/routes/api/draft.force-manual-pick.ts | 50 +- app/routes/api/draft.make-pick.ts | 90 +- .../leagues/$leagueId.draft.$seasonId.tsx | 20 + database/schema.ts | 4 +- docs/agents/deadline-based-timer.md | 282 ++++++ docs/infrastructure-roadmap.md | 281 ++++++ drizzle/0116_panoramic_human_cannonball.sql | 2 + ...{0114_snapshot.json => 0116_snapshot.json} | 14 +- drizzle/meta/_journal.json | 931 +++--------------- plans/zero-downtime-scaling.md | 251 +++++ server/__tests__/timer-autodraft.test.ts | 12 +- server/socket.ts | 30 +- server/timer.ts | 566 ++++++----- 18 files changed, 1553 insertions(+), 1368 deletions(-) create mode 100644 .production-info/docker-compose-production.yml create mode 100644 docs/agents/deadline-based-timer.md create mode 100644 docs/infrastructure-roadmap.md create mode 100644 drizzle/0116_panoramic_human_cannonball.sql rename drizzle/meta/{0114_snapshot.json => 0116_snapshot.json} (99%) create mode 100644 plans/zero-downtime-scaling.md diff --git a/.production-info/docker-compose-production.yml b/.production-info/docker-compose-production.yml new file mode 100644 index 0000000..67ccab8 --- /dev/null +++ b/.production-info/docker-compose-production.yml @@ -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 diff --git a/app/hooks/useDraftSocketEvents.ts b/app/hooks/useDraftSocketEvents.ts index da6aab2..6a88f46 100644 --- a/app/hooks/useDraftSocketEvents.ts +++ b/app/hooks/useDraftSocketEvents.ts @@ -35,6 +35,7 @@ interface UseDraftSocketEventsParams { setConnectedTeams: (fn: (prev: Set) => Set) => void; setQueue: (fn: (prev: QueueItem[]) => QueueItem[]) => void; setTeamTimers: (fn: (prev: Record) => Record) => void; + setPickTimerExpiresAt: (state: { teamId: string; expiresAt: number } | null) => void; setIsOvernightPause: (value: boolean) => void; setOvernightResumesAt: (value: Date | null) => void; setWatchedParticipantIds: (value: Set) => void; @@ -65,6 +66,7 @@ export function useDraftSocketEvents({ setConnectedTeams, setQueue, setTeamTimers, + setPickTimerExpiresAt, setIsOvernightPause, setOvernightResumesAt, setWatchedParticipantIds, @@ -108,23 +110,27 @@ export function useDraftSocketEvents({ } }; - const handleTimerUpdate = (data: { + const handleTimerPickStarted = (data: { + seasonId: string; teamId: string; + pickNumber: number; + expiresAt: number; timeRemaining: number; - currentPickNumber: number | null; - overnightPauseActive?: boolean; + }) => { + setTeamTimers((prev) => ({ ...prev, [data.teamId]: data.timeRemaining })); + setPickTimerExpiresAt({ teamId: data.teamId, expiresAt: data.expiresAt }); + setIsOvernightPause(false); + setOvernightResumesAt(null); + }; + + const handleTimerOvernightPaused = (data: { + seasonId: string; + teamId: string; 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); + setPickTimerExpiresAt(null); + setIsOvernightPause(true); + setOvernightResumesAt(data.resumesAtUTC ? new Date(data.resumesAtUTC) : null); }; const handleDraftPaused = () => setIsPaused(true); @@ -210,7 +216,7 @@ export function useDraftSocketEvents({ currentPickNumber: number; isPaused: boolean; status: string; - timers?: Array<{ teamId: string; timeRemaining: number }>; + timers?: Array<{ teamId: string; timeRemaining: number; expiresAt?: number }>; queue?: QueueItem[]; watchlistParticipantIds?: string[]; }) => { @@ -225,6 +231,11 @@ export function useDraftSocketEvents({ data.timers?.forEach((t) => { updated[t.teamId] = t.timeRemaining; }); return updated; }); + // Restore client-side countdown for the active team on reconnect. + const activeTimer = data.timers.find((t) => t.expiresAt !== undefined); + if (activeTimer?.expiresAt) { + setPickTimerExpiresAt({ teamId: activeTimer.teamId, expiresAt: activeTimer.expiresAt }); + } } if (data.queue) { const q = data.queue; @@ -258,7 +269,8 @@ export function useDraftSocketEvents({ }; on("pick-made", handlePickMade as (data: unknown) => void); - on("timer-update", handleTimerUpdate 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 +290,8 @@ export function useDraftSocketEvents({ return () => { off("pick-made", handlePickMade as (data: unknown) => void); - off("timer-update", handleTimerUpdate 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); diff --git a/app/routes/api/__tests__/draft.force-manual-pick.test.ts b/app/routes/api/__tests__/draft.force-manual-pick.test.ts index 3306277..09b7b3c 100644 --- a/app/routes/api/__tests__/draft.force-manual-pick.test.ts +++ b/app/routes/api/__tests__/draft.force-manual-pick.test.ts @@ -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); }); }); }); diff --git a/app/routes/api/__tests__/draft.force-manual-pick.timer-mode.test.ts b/app/routes/api/__tests__/draft.force-manual-pick.timer-mode.test.ts index 4b8c869..2173f66 100644 --- a/app/routes/api/__tests__/draft.force-manual-pick.timer-mode.test.ts +++ b/app/routes/api/__tests__/draft.force-manual-pick.timer-mode.test.ts @@ -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); }); }); }); diff --git a/app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts b/app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts index 16a112f..2715ad6 100644 --- a/app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts +++ b/app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts @@ -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); }); }); }); diff --git a/app/routes/api/draft.force-manual-pick.ts b/app/routes/api/draft.force-manual-pick.ts index 5752f51..33c96b9 100644 --- a/app/routes/api/draft.force-manual-pick.ts +++ b/app/routes/api/draft.force-manual-pick.ts @@ -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,6 +13,7 @@ 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"; @@ -173,41 +174,29 @@ 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 pickMadeAt = Date.now(); + const timeRemainingAtPick = timerSnapshot?.picksExpiresAt + ? Math.max(0, Math.floor((timerSnapshot.picksExpiresAt.getTime() - pickMadeAt) / 1000)) + : (timerSnapshot?.timeRemaining ?? 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 @@ -322,6 +311,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, diff --git a/app/routes/api/draft.make-pick.ts b/app/routes/api/draft.make-pick.ts index 032a43e..935bfa2 100644 --- a/app/routes/api/draft.make-pick.ts +++ b/app/routes/api/draft.make-pick.ts @@ -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"; @@ -141,13 +142,18 @@ 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 pickMadeAt = Date.now(); + const timeRemainingAtPick = timerSnapshot?.picksExpiresAt + ? Math.max(0, Math.floor((timerSnapshot.picksExpiresAt.getTime() - pickMadeAt) / 1000)) + : (timerSnapshot?.timeRemaining ?? 0); // Create the draft pick const [draftPick] = await db @@ -161,7 +167,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 +216,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,19 +251,8 @@ 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) + // rescheduleTimer (called below after the autodraft chain) will emit timer-pick-started + // for the next team — no need to emit timer-update here. // Update season's current pick number (AFTER initializing next timer to prevent race condition) await db @@ -353,6 +336,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, diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 67afda8..5c42323 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -493,6 +493,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() } : null; + }); + + useEffect(() => { + if (!pickTimerExpiresAt) return; + const { teamId, expiresAt } = pickTimerExpiresAt; + const tick = () => { + const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)); + setTeamTimers((prev) => prev[teamId] === remaining ? prev : { ...prev, [teamId]: remaining }); + }; + tick(); // immediate update so display is accurate on mount + const id = setInterval(tick, 1_000); + return () => clearInterval(id); + }, [pickTimerExpiresAt, setTeamTimers]); + useDraftSocketEvents({ on, off, @@ -514,6 +533,7 @@ export default function DraftRoom() { setConnectedTeams, setQueue, setTeamTimers, + setPickTimerExpiresAt, setIsOvernightPause, setOvernightResumesAt, setWatchedParticipantIds, diff --git a/database/schema.ts b/database/schema.ts index 35e4cdb..98a0f65 100644 --- a/database/schema.ts +++ b/database/schema.ts @@ -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(), }); diff --git a/docs/agents/deadline-based-timer.md b/docs/agents/deadline-based-timer.md new file mode 100644 index 0000000..71f0d79 --- /dev/null +++ b/docs/agents/deadline-based-timer.md @@ -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(); + +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 { + // 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 { + 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. diff --git a/docs/infrastructure-roadmap.md b/docs/infrastructure-roadmap.md new file mode 100644 index 0000000..039d594 --- /dev/null +++ b/docs/infrastructure-roadmap.md @@ -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). diff --git a/drizzle/0116_panoramic_human_cannonball.sql b/drizzle/0116_panoramic_human_cannonball.sql new file mode 100644 index 0000000..e87263b --- /dev/null +++ b/drizzle/0116_panoramic_human_cannonball.sql @@ -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; \ No newline at end of file diff --git a/drizzle/meta/0114_snapshot.json b/drizzle/meta/0116_snapshot.json similarity index 99% rename from drizzle/meta/0114_snapshot.json rename to drizzle/meta/0116_snapshot.json index f2709bb..9f7a9b3 100644 --- a/drizzle/meta/0114_snapshot.json +++ b/drizzle/meta/0116_snapshot.json @@ -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": {}, diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 6d9b869..a364dab 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] -} \ No newline at end of file +} diff --git a/plans/zero-downtime-scaling.md b/plans/zero-downtime-scaling.md new file mode 100644 index 0000000..4b37c2c --- /dev/null +++ b/plans/zero-downtime-scaling.md @@ -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 | diff --git a/server/__tests__/timer-autodraft.test.ts b/server/__tests__/timer-autodraft.test.ts index 9e6459a..000ba69 100644 --- a/server/__tests__/timer-autodraft.test.ts +++ b/server/__tests__/timer-autodraft.test.ts @@ -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 }) + ); }); }); }); diff --git a/server/socket.ts b/server/socket.ts index 585210d..ff7b7ae 100644 --- a/server/socket.ts +++ b/server/socket.ts @@ -18,11 +18,17 @@ interface ServerToClientEvents { "draft-started": (data: { seasonId: string; currentPickNumber: number }) => void; "draft-completed": () => void; "draft-room-closed": () => void; - "timer-update": (data: { + "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; @@ -60,6 +66,7 @@ interface ServerToClientEvents { timers: Array<{ teamId: string; timeRemaining: number; + expiresAt?: number; // ms timestamp; present for the currently-active team }>; queue?: Array<{ id: string; @@ -283,10 +290,19 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer { isPaused: seasonData.draftPaused || false, status: seasonData.status, 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, }); diff --git a/server/timer.ts b/server/timer.ts index 6fedafe..ef004a6 100644 --- a/server/timer.ts +++ b/server/timer.ts @@ -1,5 +1,5 @@ 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"; @@ -8,17 +8,19 @@ 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(); // seasonId → active setTimeout +const overnightResumeTimeouts = new Map(); // seasonId → resume setTimeout +const schedulingInProgress = new Set(); // 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(); // 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>(); +let recoveryInterval: NodeJS.Timeout | null = null; + async function getTeamTimezone(seasonId: string, teamId: string): Promise { let seasonMap = teamTimezoneCache.get(seasonId); if (!seasonMap) { @@ -26,13 +28,11 @@ async function getTeamTimezone(seasonId: string, teamId: string): Promise [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); + logger.error("[Timer] getTeamTimezone failed:", err); seasonMap = new Map(); } teamTimezoneCache.set(seasonId, seasonMap); @@ -40,10 +40,6 @@ async function getTeamTimezone(seasonId: string, teamId: string): Promise, currentTeamId: string @@ -58,7 +54,6 @@ async function checkOvernightPause( 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; } @@ -72,42 +67,16 @@ async function checkOvernightPause( 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 { @@ -136,8 +105,6 @@ async function checkAndAutoStartDrafts(): Promise { 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 +114,32 @@ async function checkAndAutoStartDrafts(): Promise { } } -/** - * Update all active draft timers - * Called every second by the timer interval - */ -async function updateDraftTimers(): Promise { - 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 { + 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 +157,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 +168,273 @@ async function triggerAutoPick( return false; } } + +// Core scheduling logic — called after acquiring the schedulingInProgress lock. +async function _schedulePickForSeason(seasonId: string): Promise { + 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; + } + // The chain may have advanced currentPickNumber past all while_on teams — reschedule. + await _schedulePickForSeason(seasonId); + return; + } + + // Overnight pause: freeze timer; schedule a wakeup for when the window ends. + const overnightPause = await checkOvernightPause(season, currentTeamId); + const isOvernightFreeze = overnightPause.active && !shouldAutodraft; + + if (isOvernightFreeze) { + try { + getSocketIO().to(`draft-${seasonId}`).emit("timer-overnight-paused", { + seasonId, + teamId: currentTeamId, + resumesAtUTC: overnightPause.resumesAtUTC, + }); + } catch (_) { /* non-fatal */ } + + if (overnightPause.resumesAtUTC) { + const msUntilResume = Math.max(0, overnightPause.resumesAtUTC - Date.now()); + const resumeTimeout = setTimeout(async () => { + overnightResumeTimeouts.delete(seasonId); + await schedulePickForSeason(seasonId); + }, msUntilResume + 2_000); // 2 s buffer in case clocks drift + overnightResumeTimeouts.set(seasonId, resumeTimeout); + } + return; + } + + // Get or create timer row for this team. + let timer = await db.query.draftTimers.findFirst({ + where: and( + eq(schema.draftTimers.seasonId, seasonId), + eq(schema.draftTimers.teamId, currentTeamId) + ), + }); + + if (!timer) { + const initialTime = + season.draftTimerMode === "standard" + ? (season.draftIncrementTime || 30) + : (season.draftInitialTime || 120); + const startedAt = new Date(); + const expiresAt = new Date(startedAt.getTime() + initialTime * 1000); + await db.insert(schema.draftTimers).values({ + seasonId, + teamId: currentTeamId, + timeRemaining: initialTime, + picksExpiresAt: expiresAt, + picksStartedAt: startedAt, + }); + // Re-fetch to get the full row (including generated id) + timer = await db.query.draftTimers.findFirst({ + 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. + const success = await triggerAutoPick( + seasonId, + currentTeamId, + currentPickNumber, + shouldAutodraft ? (autodraftSettings ?? null) : null + ); + if (!success) { await pauseDraftOnError(seasonId, currentTeamId); return; } + await _schedulePickForSeason(seasonId); + return; + } + 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; } + await _schedulePickForSeason(seasonId); + return; + } + + const timeout = setTimeout(async () => { + pickTimeouts.delete(seasonId); + logger.log( + `[Timer] ⚠️ Timer expired for team ${currentTeamId} in season ${seasonId} (pick ${currentPickNumber})` + ); + const success = await triggerAutoPick( + seasonId, + currentTeamId, + currentPickNumber, + shouldAutodraft ? (autodraftSettings ?? null) : null + ); + if (!success) { + await pauseDraftOnError(seasonId, currentTeamId); + return; + } + await schedulePickForSeason(seasonId); + }, msUntilExpiry); + + pickTimeouts.set(seasonId, timeout); +} + +async function schedulePickForSeason(seasonId: string): Promise { + 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 { + await checkAndAutoStartDrafts(); + + const activeDrafts = await db.query.seasons.findMany({ + where: eq(schema.seasons.status, "draft"), + }); + + // Evict caches for seasons no longer drafting. + const activeIds = new Set(activeDrafts.map((s) => s.id)); + for (const id of draftSlotsCache.keys()) { + if (!activeIds.has(id)) draftSlotsCache.delete(id); + } + for (const id of teamTimezoneCache.keys()) { + if (!activeIds.has(id)) teamTimezoneCache.delete(id); + } + + 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(); + teamTimezoneCache.clear(); + 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 { + cancelPickTimeout(seasonId); + cancelOvernightResumeTimeout(seasonId); + draftSlotsCache.delete(seasonId); // pick may have changed the draft order state + await schedulePickForSeason(seasonId); +} -- 2.45.3 From 0eb6715655a19040771b8af35f1c2eff68063b6a Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Fri, 5 Jun 2026 22:42:19 -0700 Subject: [PATCH 2/2] Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Broadcast timer-bank-updated after every pick so all connected clients immediately see the updated time bank (was only visible on next timer-pick-started) - Capture pickMadeAt at route entry (before auth/DB overhead) and use Math.ceil so credited seconds always match the client countdown display - Clear picksExpiresAt on every pick so _schedulePickForSeason starts fresh - Hold schedulingInProgress lock for full timer callback to prevent the recovery interval from scheduling a duplicate timeout mid-pick - Fix force-autopick route: call rescheduleTimer so the next team's clock starts immediately instead of waiting for the old timeout to fire naturally - Fix draft.adjust-time-bank for on-clock teams: shift picksExpiresAt by the adjustment and reschedule, so the client countdown updates; block adjustments that would reduce the bank to zero - Add timer-pick-started / timer-overnight-paused / timer-bank-updated socket events with full type definitions; replace dead timer-update event - Fix draft-state-sync to include expiresAt for the active timer and isOvernightPause state so reconnecting clients see accurate countdown and pause banner immediately - Fix room-closure countdown: capture client-side timestamp when draft completes so countdown runs even before the loader revalidates with draftCompletedAt - Run countdown interval at 500ms with Math.ceil to prevent skipped seconds - Add draft-started socket handler to transition pre-draft UI without a refresh - Fix overnight pause: canPick only blocks on commissioner pause, not overnight pause (timer freezes but player can still pick early) - Extract checkOvernightPause to server/overnight-pause-check.ts, breaking the timer↔socket circular import and ensuring the timezone cache is shared and evicted correctly across both callers - Fix PostgreSQL varchar=uuid type mismatch in getTeamTimezone join Co-Authored-By: Claude Sonnet 4.6 --- app/hooks/useDraftSocketEvents.ts | 36 +- .../__tests__/executeAutoPick.timer.test.ts | 32 +- app/models/draft-utils.ts | 129 +++--- app/routes/api/draft.adjust-time-bank.ts | 58 ++- app/routes/api/draft.force-autopick.ts | 9 + app/routes/api/draft.force-manual-pick.ts | 28 +- app/routes/api/draft.make-pick.ts | 36 +- app/routes/api/draft.pause.ts | 8 + app/routes/api/draft.resume.ts | 8 + app/routes/api/draft.rollback.ts | 24 +- app/routes/api/draft.start.ts | 9 + .../leagues/$leagueId.draft.$seasonId.tsx | 32 +- server/overnight-pause-check.ts | 68 +++ server/socket.ts | 34 +- server/timer.ts | 435 +++++++++--------- 15 files changed, 588 insertions(+), 358 deletions(-) create mode 100644 server/overnight-pause-check.ts diff --git a/app/hooks/useDraftSocketEvents.ts b/app/hooks/useDraftSocketEvents.ts index 6a88f46..30f7c88 100644 --- a/app/hooks/useDraftSocketEvents.ts +++ b/app/hooks/useDraftSocketEvents.ts @@ -82,6 +82,8 @@ export function useDraftSocketEvents({ isDraftComplete?: boolean; }; const handlePickMade = (data: PickMadePayload) => { + // Stop any running countdown — timer-pick-started for the next team will restart it. + setPickTimerExpiresAt(null); if (isRevalidatingRef.current) { pendingPicksDuringRevalidationRef.current.push(data.pick); } else { @@ -117,12 +119,17 @@ export function useDraftSocketEvents({ expiresAt: number; timeRemaining: number; }) => { + setCurrentPick(data.pickNumber); setTeamTimers((prev) => ({ ...prev, [data.teamId]: data.timeRemaining })); setPickTimerExpiresAt({ teamId: data.teamId, expiresAt: data.expiresAt }); setIsOvernightPause(false); setOvernightResumesAt(null); }; + const handleTimerBankUpdated = (data: { teamId: string; timeRemaining: number }) => { + setTeamTimers((prev) => ({ ...prev, [data.teamId]: data.timeRemaining })); + }; + const handleTimerOvernightPaused = (data: { seasonId: string; teamId: string; @@ -133,7 +140,10 @@ export function useDraftSocketEvents({ setOvernightResumesAt(data.resumesAtUTC ? new Date(data.resumesAtUTC) : null); }; - const handleDraftPaused = () => setIsPaused(true); + const handleDraftPaused = () => { + setIsPaused(true); + setPickTimerExpiresAt(null); + }; const handleDraftResumed = () => setIsPaused(false); const handleDraftCompleted = () => setIsDraftComplete(true); @@ -208,7 +218,8 @@ export function useDraftSocketEvents({ setPicks((prev) => prev.filter((p) => p.pickNumber < data.pickNumber)); setCurrentPick(data.pickNumber); setIsDraftComplete(false); - setIsPaused(false); + setIsPaused(true); + setPickTimerExpiresAt(null); }; const handleDraftStateSync = (data: { @@ -216,6 +227,8 @@ export function useDraftSocketEvents({ currentPickNumber: number; isPaused: boolean; status: string; + isOvernightPause?: boolean; + overnightResumesAt?: number; timers?: Array<{ teamId: string; timeRemaining: number; expiresAt?: number }>; queue?: QueueItem[]; watchlistParticipantIds?: string[]; @@ -231,12 +244,25 @@ export function useDraftSocketEvents({ data.timers?.forEach((t) => { updated[t.teamId] = t.timeRemaining; }); return updated; }); - // Restore client-side countdown for the active team on reconnect. - const activeTimer = data.timers.find((t) => t.expiresAt !== undefined); + // Restore countdown for the active team. Filter out already-expired timestamps + // so a reconnect after autopick fired doesn't briefly show a stale countdown. + const now = Date.now(); + const activeTimer = data.timers.find((t) => t.expiresAt !== undefined && t.expiresAt > now); if (activeTimer?.expiresAt) { setPickTimerExpiresAt({ teamId: activeTimer.teamId, expiresAt: activeTimer.expiresAt }); + } else { + setPickTimerExpiresAt(null); } } + // Restore overnight-pause state for clients that connect mid-pause. + if (data.isOvernightPause) { + setIsOvernightPause(true); + setOvernightResumesAt(data.overnightResumesAt ? new Date(data.overnightResumesAt) : null); + setPickTimerExpiresAt(null); + } else { + setIsOvernightPause(false); + setOvernightResumesAt(null); + } if (data.queue) { const q = data.queue; setQueue(() => q); @@ -269,6 +295,7 @@ export function useDraftSocketEvents({ }; on("pick-made", handlePickMade as (data: unknown) => void); + on("timer-bank-updated", handleTimerBankUpdated as (data: unknown) => void); on("timer-pick-started", handleTimerPickStarted as (data: unknown) => void); on("timer-overnight-paused", handleTimerOvernightPaused as (data: unknown) => void); on("draft-paused", handleDraftPaused as (data: unknown) => void); @@ -290,6 +317,7 @@ export function useDraftSocketEvents({ return () => { off("pick-made", handlePickMade as (data: unknown) => void); + off("timer-bank-updated", handleTimerBankUpdated as (data: unknown) => void); off("timer-pick-started", handleTimerPickStarted as (data: unknown) => void); off("timer-overnight-paused", handleTimerOvernightPaused as (data: unknown) => void); off("draft-paused", handleDraftPaused as (data: unknown) => void); diff --git a/app/models/__tests__/executeAutoPick.timer.test.ts b/app/models/__tests__/executeAutoPick.timer.test.ts index 593d8bf..db7c34a 100644 --- a/app/models/__tests__/executeAutoPick.timer.test.ts +++ b/app/models/__tests__/executeAutoPick.timer.test.ts @@ -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 }) ); }); diff --git a/app/models/draft-utils.ts b/app/models/draft-utils.ts index 053f0ed..bdfccd8 100644 --- a/app/models/draft-utils.ts +++ b/app/models/draft-utils.ts @@ -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; 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. diff --git a/app/routes/api/draft.adjust-time-bank.ts b/app/routes/api/draft.adjust-time-bank.ts index 3bf01ab..a928f2d 100644 --- a/app/routes/api/draft.adjust-time-bank.ts +++ b/app/routes/api/draft.adjust-time-bank.ts @@ -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 }); diff --git a/app/routes/api/draft.force-autopick.ts b/app/routes/api/draft.force-autopick.ts index 3f809c9..d3e2152 100644 --- a/app/routes/api/draft.force-autopick.ts +++ b/app/routes/api/draft.force-autopick.ts @@ -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), }); diff --git a/app/routes/api/draft.force-manual-pick.ts b/app/routes/api/draft.force-manual-pick.ts index 33c96b9..a21c65c 100644 --- a/app/routes/api/draft.force-manual-pick.ts +++ b/app/routes/api/draft.force-manual-pick.ts @@ -19,6 +19,7 @@ import { runBracktHarvilleForFantasySeason } from "~/services/brackt.server"; import type { ActionFunctionArgs } from "react-router"; export async function action(args: ActionFunctionArgs) { + const pickMadeAt = Date.now(); // capture before any async work for accurate timer credit const { request } = args; const session = await auth.api.getSession({ headers: args.request.headers }); const userId = session?.user.id ?? null; @@ -180,10 +181,11 @@ export async function action(args: ActionFunctionArgs) { const timerSnapshot = await db.query.draftTimers.findFirst({ where: and(eq(schema.draftTimers.seasonId, seasonId), eq(schema.draftTimers.teamId, teamId)), }); - const pickMadeAt = Date.now(); - const timeRemainingAtPick = timerSnapshot?.picksExpiresAt - ? Math.max(0, Math.floor((timerSnapshot.picksExpiresAt.getTime() - pickMadeAt) / 1000)) - : (timerSnapshot?.timeRemaining ?? 0); + const timeRemainingAtPick = (() => { + if (!timerSnapshot?.picksExpiresAt) return timerSnapshot?.timeRemaining ?? 0; + const msRemaining = timerSnapshot.picksExpiresAt.getTime() - pickMadeAt; + return msRemaining > 0 ? Math.ceil(msRemaining / 1000) : 0; + })(); const newTimeRemaining = season.draftTimerMode === "standard" ? incrementTime : timeRemainingAtPick + incrementTime; @@ -246,6 +248,9 @@ export async function action(args: ActionFunctionArgs) { isDraftComplete, }); + // Emit timer bank update AFTER pick-made so handlePickMade stops the countdown first. + io.to(`draft-${seasonId}`).emit("timer-bank-updated", { teamId, timeRemaining: newTimeRemaining }); + if (isDraftComplete) { io.to(`draft-${seasonId}`).emit("draft-completed"); scheduleDraftRoomClosure(seasonId); @@ -290,14 +295,13 @@ export async function action(args: ActionFunctionArgs) { // Check if next team has autodraft enabled and trigger immediately if (!isDraftComplete) { - try { - const updates = await runBracktHarvilleForFantasySeason(seasonId, db); - if (updates.length > 0) { - getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates }); - } - } catch (error) { - logger.error("Brackt EV update after forced manual pick failed:", error); - } + runBracktHarvilleForFantasySeason(seasonId, db) + .then((updates) => { + if (updates.length > 0) { + getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates }); + } + }) + .catch((error) => logger.error("Brackt EV update after forced manual pick failed:", error)); const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) }); if (!freshSeason?.draftPaused) { diff --git a/app/routes/api/draft.make-pick.ts b/app/routes/api/draft.make-pick.ts index 935bfa2..2ddd5de 100644 --- a/app/routes/api/draft.make-pick.ts +++ b/app/routes/api/draft.make-pick.ts @@ -19,6 +19,7 @@ import { enqueuePickNotification } from "~/services/discord"; import type { ActionFunctionArgs } from "react-router"; export async function action(args: ActionFunctionArgs) { + const pickMadeAt = Date.now(); // capture before any async work for accurate timer credit const { request } = args; const session = await auth.api.getSession({ headers: args.request.headers }); const userId = session?.user.id ?? null; @@ -150,10 +151,11 @@ export async function action(args: ActionFunctionArgs) { eq(schema.draftTimers.teamId, currentDraftSlot.teamId) ), }); - const pickMadeAt = Date.now(); - const timeRemainingAtPick = timerSnapshot?.picksExpiresAt - ? Math.max(0, Math.floor((timerSnapshot.picksExpiresAt.getTime() - pickMadeAt) / 1000)) - : (timerSnapshot?.timeRemaining ?? 0); + const timeRemainingAtPick = (() => { + if (!timerSnapshot?.picksExpiresAt) return timerSnapshot?.timeRemaining ?? 0; + const msRemaining = timerSnapshot.picksExpiresAt.getTime() - pickMadeAt; + return msRemaining > 0 ? Math.ceil(msRemaining / 1000) : 0; + })(); // Create the draft pick const [draftPick] = await db @@ -251,9 +253,6 @@ export async function action(args: ActionFunctionArgs) { }); } - // rescheduleTimer (called below after the autodraft chain) will emit timer-pick-started - // for the next team — no need to emit timer-update here. - // Update season's current pick number (AFTER initializing next timer to prevent race condition) await db .update(schema.seasons) @@ -280,6 +279,13 @@ export async function action(args: ActionFunctionArgs) { isDraftComplete, }); + // Emit timer bank update AFTER pick-made so handlePickMade stops the countdown + // interval first — otherwise the interval could tick 0 and overwrite the new bank. + getSocketIO().to(`draft-${seasonId}`).emit("timer-bank-updated", { + teamId: currentDraftSlot.teamId, + timeRemaining: newTimeRemaining, + }); + if (isDraftComplete) { getSocketIO().to(`draft-${seasonId}`).emit("draft-completed"); scheduleDraftRoomClosure(seasonId); @@ -310,14 +316,14 @@ export async function action(args: ActionFunctionArgs) { // Check if next team has autodraft enabled and trigger immediately if (!isDraftComplete) { - try { - const updates = await runBracktHarvilleForFantasySeason(seasonId, db); - if (updates.length > 0) { - getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates }); - } - } catch (error) { - logger.error("Brackt EV update after pick failed:", error); - } + // Fire-and-forget so it doesn't delay rescheduleTimer (and timer-pick-started) for the next team. + runBracktHarvilleForFantasySeason(seasonId, db) + .then((updates) => { + if (updates.length > 0) { + getSocketIO().to(`draft-${seasonId}`).emit("brackt-evs-updated", { updates }); + } + }) + .catch((error) => logger.error("Brackt EV update after pick failed:", error)); const freshSeason = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId) }); if (!freshSeason?.draftPaused) { diff --git a/app/routes/api/draft.pause.ts b/app/routes/api/draft.pause.ts index 1466603..fe01c51 100644 --- a/app/routes/api/draft.pause.ts +++ b/app/routes/api/draft.pause.ts @@ -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", { diff --git a/app/routes/api/draft.resume.ts b/app/routes/api/draft.resume.ts index 3dd27a8..5494073 100644 --- a/app/routes/api/draft.resume.ts +++ b/app/routes/api/draft.resume.ts @@ -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", { diff --git a/app/routes/api/draft.rollback.ts b/app/routes/api/draft.rollback.ts index 656b718..3cf02ae 100644 --- a/app/routes/api/draft.rollback.ts +++ b/app/routes/api/draft.rollback.ts @@ -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 }); } diff --git a/app/routes/api/draft.start.ts b/app/routes/api/draft.start.ts index 9ccdafc..4c567e0 100644 --- a/app/routes/api/draft.start.ts +++ b/app/routes/api/draft.start.ts @@ -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 }); } diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 5c42323..455ff61 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -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(null); + if (isDraftComplete && draftCompletedAtClientRef.current === null) { + draftCompletedAtClientRef.current = season.draftCompletedAt?.getTime() ?? Date.now(); + } + const roomClosureCountdown = useMemo(() => { if (!isDraftComplete) return null; - const completedAt = season.draftCompletedAt; - if (!completedAt) return 300; - const elapsed = (nowForPauseCheck.getTime() - completedAt.getTime()) / 1000; - const remaining = Math.max(0, 300 - elapsed); - return Math.ceil(remaining); + const baseMs = season.draftCompletedAt?.getTime() ?? draftCompletedAtClientRef.current ?? Date.now(); + const elapsed = (nowForPauseCheck.getTime() - baseMs) / 1000; + return Math.max(0, Math.ceil(300 - elapsed)); }, [isDraftComplete, season.draftCompletedAt, nowForPauseCheck]); useEffect(() => { @@ -497,18 +504,18 @@ export default function DraftRoom() { const [pickTimerExpiresAt, setPickTimerExpiresAt] = useState<{ teamId: string; expiresAt: number } | null>(() => { // Seed from initial loader data if a timer is already running. const active = timers.find((t) => t.picksExpiresAt && t.picksExpiresAt.getTime() > Date.now()); - return active ? { teamId: active.teamId, expiresAt: active.picksExpiresAt!.getTime() } : null; + return active ? { teamId: active.teamId, expiresAt: active.picksExpiresAt?.getTime() ?? 0 } : null; }); useEffect(() => { if (!pickTimerExpiresAt) return; const { teamId, expiresAt } = pickTimerExpiresAt; const tick = () => { - const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)); + const remaining = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)); setTeamTimers((prev) => prev[teamId] === remaining ? prev : { ...prev, [teamId]: remaining }); }; tick(); // immediate update so display is accurate on mount - const id = setInterval(tick, 1_000); + const id = setInterval(tick, 500); return () => clearInterval(id); }, [pickTimerExpiresAt, setTeamTimers]); @@ -550,6 +557,13 @@ export default function DraftRoom() { } }, [sidebarCollapsed]); + // Revalidate loader data when the draft auto-starts so the pre-draft UI transitions without a refresh. + useEffect(() => { + const handleDraftStarted = () => revalidate(); + on("draft-started", handleDraftStarted); + return () => off("draft-started", handleDraftStarted); + }, [on, off, revalidate]); + // Queue handlers const handleAddToQueue = useCallback(async (participantId: string) => { if (!userTeam) return; @@ -1039,7 +1053,7 @@ export default function DraftRoom() { const isMyTurn = !!( userTeam && currentDraftSlot && currentDraftSlot.team.id === userTeam.id ); - const canPick = isMyTurn && season.status === "draft" && !isPaused; // Only team owner on their turn when draft is active + const canPick = isMyTurn && season.status === "draft" && !isPaused; const currentDraftSlotOwnerName = currentDraftSlot ? ownerMap[currentDraftSlot.team.id] : undefined; const currentClockTime = currentDraftSlot ? teamTimers[currentDraftSlot.team.id] : undefined; diff --git a/server/overnight-pause-check.ts b/server/overnight-pause-check.ts new file mode 100644 index 0000000..052f1c8 --- /dev/null +++ b/server/overnight-pause-check.ts @@ -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>(); + +async function getTeamTimezone(seasonId: string, teamId: string): Promise { + 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, + 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 { + return teamTimezoneCache.keys(); +} + +export function clearAllOvernightPauseCaches(): void { + teamTimezoneCache.clear(); +} diff --git a/server/socket.ts b/server/socket.ts index ff7b7ae..91bdab6 100644 --- a/server/socket.ts +++ b/server/socket.ts @@ -3,6 +3,8 @@ import { Server as SocketIOServer } from "socket.io"; import type { Server as HTTPServer } from "http"; import * as schema from "~/database/schema"; import { eq, and, asc } from "drizzle-orm"; +import { checkOvernightPause } from "./overnight-pause-check"; +import { calculatePickInfo } from "~/models/draft-utils"; import { logger } from "./logger"; import { db } from "./db"; @@ -18,6 +20,7 @@ interface ServerToClientEvents { "draft-started": (data: { seasonId: string; currentPickNumber: number }) => void; "draft-completed": () => void; "draft-room-closed": () => void; + "timer-bank-updated": (data: { teamId: string; timeRemaining: number }) => void; "timer-pick-started": (data: { seasonId: string; teamId: string; @@ -53,6 +56,8 @@ interface ServerToClientEvents { currentPickNumber: number; isPaused: boolean; status: string; + isOvernightPause: boolean; + overnightResumesAt?: number; picks: Array<{ id: string; pickNumber: number; @@ -231,7 +236,7 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer { // tokens or flaky mobile networks. This socket-based sync provides an // additional, more reliable path since the socket is already connected. try { - const [seasonData, picks, timerRows, queueItems, watchlistItems] = await Promise.all([ + const [seasonData, picks, timerRows, draftSlots, queueItems, watchlistItems] = await Promise.all([ db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId), }), @@ -265,6 +270,10 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer { db.query.draftTimers.findMany({ where: eq(schema.draftTimers.seasonId, seasonId), }), + db.query.draftSlots.findMany({ + where: eq(schema.draftSlots.seasonId, seasonId), + orderBy: asc(schema.draftSlots.draftOrder), + }), teamId ? db.query.draftQueue.findMany({ where: and( @@ -285,10 +294,33 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer { ]); if (seasonData) { + // Compute overnight-pause state so reconnecting clients see it immediately. + let isOvernightPause = false; + let overnightResumesAt: number | undefined; + + if ( + draftSlots.length > 0 && + seasonData.status === "draft" && + !seasonData.draftPaused + ) { + const { pickInRound } = calculatePickInfo( + seasonData.currentPickNumber ?? 1, + draftSlots.length + ); + const currentSlot = draftSlots.find((s) => s.draftOrder === pickInRound); + if (currentSlot) { + const result = await checkOvernightPause(seasonData, currentSlot.teamId); + isOvernightPause = result.active; + overnightResumesAt = result.resumesAtUTC; + } + } + socket.emit("draft-state-sync", { currentPickNumber: seasonData.currentPickNumber || 1, isPaused: seasonData.draftPaused || false, status: seasonData.status, + isOvernightPause, + overnightResumesAt, picks, timers: timerRows.map((t) => { const nowMs = Date.now(); diff --git a/server/timer.ts b/server/timer.ts index ef004a6..98f079f 100644 --- a/server/timer.ts +++ b/server/timer.ts @@ -3,7 +3,7 @@ import { eq, and, asc, lte, isNotNull } from "drizzle-orm"; import type { InferSelectModel } from "drizzle-orm"; import { getSocketIO } from "./socket"; import { executeAutoPick, calculatePickInfo } from "~/models/draft-utils"; -import { isInOvernightWindow, getOvernightResumeUTC } from "~/lib/overnight-pause"; +import { checkOvernightPause, evictOvernightPauseCache, overnightPauseCacheKeys, clearAllOvernightPauseCaches } from "./overnight-pause-check"; import { logger } from "./logger"; import { db } from "./db"; import { startDraft } from "~/services/draft-autostart"; @@ -16,57 +16,8 @@ const schedulingInProgress = new Set(); // prevents conc // Draft slots never change during an active draft — cache them to avoid a redundant query. const draftSlotsCache = new Map(); -// Team timezone cache for per_user overnight pause mode (seasonId → teamId → timezone). -const teamTimezoneCache = new Map>(); - let recoveryInterval: NodeJS.Timeout | null = null; -async function getTeamTimezone(seasonId: string, teamId: string): Promise { - let seasonMap = teamTimezoneCache.get(seasonId); - if (!seasonMap) { - try { - const rows = await db - .select({ teamId: schema.teams.id, timezone: schema.users.timezone }) - .from(schema.teams) - .leftJoin(schema.users, eq(schema.teams.ownerId, schema.users.id)) - .where(eq(schema.teams.seasonId, seasonId)); - seasonMap = new Map(rows.map((r) => [r.teamId, r.timezone || null])); - } catch (err) { - logger.error("[Timer] getTeamTimezone failed:", err); - seasonMap = new Map(); - } - teamTimezoneCache.set(seasonId, seasonMap); - } - return seasonMap.get(teamId) ?? null; -} - -async function checkOvernightPause( - season: InferSelectModel, - currentTeamId: string -): Promise<{ active: boolean; resumesAtUTC?: number }> { - const mode = season.overnightPauseMode; - const start = season.overnightPauseStart; - const end = season.overnightPauseEnd; - - if (mode === "none" || !start || !end) return { active: false }; - - let tz: string | null = null; - if (mode === "league") { - tz = season.overnightPauseTimezone || null; - } else { - tz = await getTeamTimezone(season.id, currentTeamId); - if (!tz) tz = season.overnightPauseTimezone || null; - } - - if (!tz) return { active: false }; - - if (isInOvernightWindow(tz, start, end)) { - const resumesAt = getOvernightResumeUTC(tz, end); - return { active: true, resumesAtUTC: resumesAt.getTime() }; - } - return { active: false }; -} - async function getDraftSlotsCached(seasonId: string): Promise<{ teamId: string; draftOrder: number }[]> { let slots = draftSlotsCache.get(seasonId); if (!slots) { @@ -170,121 +121,158 @@ async function triggerAutoPick( } // Core scheduling logic — called after acquiring the schedulingInProgress lock. +// Iterative rather than recursive so while_on chains and bank-depleted runs don't +// build unbounded call-stack depth or bypass the outer lock via re-entry. async function _schedulePickForSeason(seasonId: string): Promise { - const season = await db.query.seasons.findFirst({ - where: eq(schema.seasons.id, seasonId), - }); - if (!season || season.status !== "draft" || season.draftPaused) return; + const MAX_CONSECUTIVE_AUTOPICKS = 100; // safety cap against infinite loops on data bugs - const draftSlots = await getDraftSlotsCached(seasonId); - const totalTeams = draftSlots.length; - if (totalTeams === 0) return; + for (let iteration = 0; iteration < MAX_CONSECUTIVE_AUTOPICKS; iteration++) { + const season = await db.query.seasons.findFirst({ + where: eq(schema.seasons.id, seasonId), + }); + if (!season || season.status !== "draft" || season.draftPaused) return; - const currentPickNumber = season.currentPickNumber ?? 1; - const { pickInRound } = calculatePickInfo(currentPickNumber, totalTeams); - const currentDraftSlot = draftSlots.find((s) => s.draftOrder === pickInRound); - if (!currentDraftSlot) return; + const draftSlots = await getDraftSlotsCached(seasonId); + const totalTeams = draftSlots.length; + if (totalTeams === 0) return; - const currentTeamId = currentDraftSlot.teamId; + const currentPickNumber = season.currentPickNumber ?? 1; + const { pickInRound } = calculatePickInfo(currentPickNumber, totalTeams); + const currentDraftSlot = draftSlots.find((s) => s.draftOrder === pickInRound); + if (!currentDraftSlot) return; - const autodraftSettings = await db.query.autodraftSettings.findFirst({ - where: and( - eq(schema.autodraftSettings.seasonId, seasonId), - eq(schema.autodraftSettings.teamId, currentTeamId) - ), - }); - const shouldAutodraft = autodraftSettings?.isEnabled ?? false; - const isWhileOn = shouldAutodraft && autodraftSettings?.mode === "while_on"; + const currentTeamId = currentDraftSlot.teamId; - if (isWhileOn) { - logger.log(`[Timer] ⚡ while_on autodraft — immediate pick for team ${currentTeamId} in season ${seasonId} (pick ${currentPickNumber})`); - try { - getSocketIO().to(`draft-${seasonId}`).emit("timer-pick-started", { - seasonId, - teamId: currentTeamId, - pickNumber: currentPickNumber, - expiresAt: Date.now(), - timeRemaining: 0, - }); - } catch (_) { /* non-fatal */ } + const autodraftSettings = await db.query.autodraftSettings.findFirst({ + where: and( + eq(schema.autodraftSettings.seasonId, seasonId), + eq(schema.autodraftSettings.teamId, currentTeamId) + ), + }); + const shouldAutodraft = autodraftSettings?.isEnabled ?? false; + const isWhileOn = shouldAutodraft && autodraftSettings?.mode === "while_on"; - const success = await triggerAutoPick(seasonId, currentTeamId, currentPickNumber, autodraftSettings ?? null); - if (!success) { - await pauseDraftOnError(seasonId, currentTeamId); + if (isWhileOn) { + logger.log(`[Timer] ⚡ while_on autodraft — immediate pick for team ${currentTeamId} in season ${seasonId} (pick ${currentPickNumber})`); + try { + getSocketIO().to(`draft-${seasonId}`).emit("timer-pick-started", { + seasonId, + teamId: currentTeamId, + pickNumber: currentPickNumber, + expiresAt: Date.now(), + timeRemaining: 0, + }); + } catch { /* non-fatal */ } + + const success = await triggerAutoPick(seasonId, currentTeamId, currentPickNumber, autodraftSettings ?? null); + if (!success) { await pauseDraftOnError(seasonId, currentTeamId); return; } + continue; // re-read season state — currentPickNumber has advanced + } + + // Overnight pause: freeze timer; schedule a wakeup for when the window ends. + const overnightPause = await checkOvernightPause(season, currentTeamId); + const isOvernightFreeze = overnightPause.active && !shouldAutodraft; + + if (isOvernightFreeze) { + try { + getSocketIO().to(`draft-${seasonId}`).emit("timer-overnight-paused", { + seasonId, + teamId: currentTeamId, + resumesAtUTC: overnightPause.resumesAtUTC, + }); + } catch { /* non-fatal */ } + + if (overnightPause.resumesAtUTC) { + const msUntilResume = Math.max(0, overnightPause.resumesAtUTC - Date.now()); + const resumeTimeout = setTimeout(async () => { + overnightResumeTimeouts.delete(seasonId); + await schedulePickForSeason(seasonId); + }, msUntilResume + 2_000); // 2 s buffer in case clocks drift + overnightResumeTimeouts.set(seasonId, resumeTimeout); + } return; } - // The chain may have advanced currentPickNumber past all while_on teams — reschedule. - await _schedulePickForSeason(seasonId); - return; - } - // Overnight pause: freeze timer; schedule a wakeup for when the window ends. - const overnightPause = await checkOvernightPause(season, currentTeamId); - const isOvernightFreeze = overnightPause.active && !shouldAutodraft; - - if (isOvernightFreeze) { - try { - getSocketIO().to(`draft-${seasonId}`).emit("timer-overnight-paused", { - seasonId, - teamId: currentTeamId, - resumesAtUTC: overnightPause.resumesAtUTC, - }); - } catch (_) { /* non-fatal */ } - - if (overnightPause.resumesAtUTC) { - const msUntilResume = Math.max(0, overnightPause.resumesAtUTC - Date.now()); - const resumeTimeout = setTimeout(async () => { - overnightResumeTimeouts.delete(seasonId); - await schedulePickForSeason(seasonId); - }, msUntilResume + 2_000); // 2 s buffer in case clocks drift - overnightResumeTimeouts.set(seasonId, resumeTimeout); - } - return; - } - - // Get or create timer row for this team. - let timer = await db.query.draftTimers.findFirst({ - where: and( - eq(schema.draftTimers.seasonId, seasonId), - eq(schema.draftTimers.teamId, currentTeamId) - ), - }); - - if (!timer) { - const initialTime = - season.draftTimerMode === "standard" - ? (season.draftIncrementTime || 30) - : (season.draftInitialTime || 120); - const startedAt = new Date(); - const expiresAt = new Date(startedAt.getTime() + initialTime * 1000); - await db.insert(schema.draftTimers).values({ - seasonId, - teamId: currentTeamId, - timeRemaining: initialTime, - picksExpiresAt: expiresAt, - picksStartedAt: startedAt, - }); - // Re-fetch to get the full row (including generated id) - timer = await db.query.draftTimers.findFirst({ + // Get or create timer row for this team. + let timer = await db.query.draftTimers.findFirst({ where: and( eq(schema.draftTimers.seasonId, seasonId), eq(schema.draftTimers.teamId, currentTeamId) ), }); - if (!timer) return; // unexpected - } - const now = Date.now(); - let expiresAt: Date; + if (!timer) { + const initialTime = + season.draftTimerMode === "standard" + ? (season.draftIncrementTime || 30) + : (season.draftInitialTime || 120); + const startedAt = new Date(); + const expiresAt = new Date(startedAt.getTime() + initialTime * 1000); + await db.insert(schema.draftTimers).values({ + seasonId, + teamId: currentTeamId, + timeRemaining: initialTime, + picksExpiresAt: expiresAt, + picksStartedAt: startedAt, + }); + // Re-fetch to get the full row (including generated id) + timer = await db.query.draftTimers.findFirst({ + where: and( + eq(schema.draftTimers.seasonId, seasonId), + eq(schema.draftTimers.teamId, currentTeamId) + ), + }); + if (!timer) return; // unexpected + } - if (timer.picksExpiresAt && timer.picksExpiresAt.getTime() > now) { - // Timer was already running (e.g. process restarted mid-pick). Honour the existing expiry. - expiresAt = timer.picksExpiresAt; - } else { - // Fresh start of this team's turn (normal path after a pick or on startup). - const bank = timer.timeRemaining; - if (bank <= 0) { - // Bank depleted — trigger autopick immediately. + const now = Date.now(); + let expiresAt: Date; + + if (timer.picksExpiresAt && timer.picksExpiresAt.getTime() > now) { + // Timer was already running (e.g. process restarted mid-pick). Honour the existing expiry. + expiresAt = timer.picksExpiresAt; + } else { + // Fresh start of this team's turn (normal path after a pick or on startup). + const bank = timer.timeRemaining; + if (bank <= 0) { + // Bank depleted — trigger autopick immediately and loop to schedule the next team. + const success = await triggerAutoPick( + seasonId, + currentTeamId, + currentPickNumber, + shouldAutodraft ? (autodraftSettings ?? null) : null + ); + if (!success) { await pauseDraftOnError(seasonId, currentTeamId); return; } + continue; + } + expiresAt = new Date(now + bank * 1000); + await db + .update(schema.draftTimers) + .set({ picksExpiresAt: expiresAt, picksStartedAt: new Date() }) + .where( + and( + eq(schema.draftTimers.seasonId, seasonId), + eq(schema.draftTimers.teamId, currentTeamId) + ) + ); + } + + const msUntilExpiry = expiresAt.getTime() - now; + const timeRemaining = Math.max(0, Math.ceil(msUntilExpiry / 1000)); + + // Tell clients to start their local countdown. + try { + getSocketIO().to(`draft-${seasonId}`).emit("timer-pick-started", { + seasonId, + teamId: currentTeamId, + pickNumber: currentPickNumber, + expiresAt: expiresAt.getTime(), + timeRemaining, + }); + } catch { /* non-fatal */ } + + if (msUntilExpiry <= 0) { + // Already expired (e.g. picked up on recovery interval after a long pause). const success = await triggerAutoPick( seasonId, currentTeamId, @@ -292,67 +280,47 @@ async function _schedulePickForSeason(seasonId: string): Promise { shouldAutodraft ? (autodraftSettings ?? null) : null ); if (!success) { await pauseDraftOnError(seasonId, currentTeamId); return; } - await _schedulePickForSeason(seasonId); - return; + continue; } - expiresAt = new Date(now + bank * 1000); - await db - .update(schema.draftTimers) - .set({ picksExpiresAt: expiresAt, picksStartedAt: new Date() }) - .where( - and( - eq(schema.draftTimers.seasonId, seasonId), - eq(schema.draftTimers.teamId, currentTeamId) - ) - ); + + const timeout = setTimeout(async () => { + pickTimeouts.delete(seasonId); + // Hold the schedulingInProgress lock for the entire pick+reschedule sequence. + // Without this, the recovery interval (which checks !pickTimeouts.has) could fire + // between the delete above and the DB commit inside triggerAutoPick, read a stale + // currentPickNumber, and schedule a duplicate timeout for the same pick. + if (schedulingInProgress.has(seasonId)) { + logger.warn(`[Timer] Skipping expired-timer callback for ${seasonId} — scheduling already in progress`); + return; + } + schedulingInProgress.add(seasonId); + try { + logger.log( + `[Timer] ⚠️ Timer expired for team ${currentTeamId} in season ${seasonId} (pick ${currentPickNumber})` + ); + const success = await triggerAutoPick( + seasonId, + currentTeamId, + currentPickNumber, + shouldAutodraft ? (autodraftSettings ?? null) : null + ); + if (!success) { + await pauseDraftOnError(seasonId, currentTeamId); + return; + } + await _schedulePickForSeason(seasonId); + } catch (err) { + logger.error(`[Timer] Error in timer callback for ${seasonId}:`, err); + } finally { + schedulingInProgress.delete(seasonId); + } + }, msUntilExpiry); + + pickTimeouts.set(seasonId, timeout); + return; // timer is set — done until it fires } - const msUntilExpiry = expiresAt.getTime() - now; - const timeRemaining = Math.max(0, Math.ceil(msUntilExpiry / 1000)); - - // Tell clients to start their local countdown. - try { - getSocketIO().to(`draft-${seasonId}`).emit("timer-pick-started", { - seasonId, - teamId: currentTeamId, - pickNumber: currentPickNumber, - expiresAt: expiresAt.getTime(), - timeRemaining, - }); - } catch (_) { /* non-fatal */ } - - if (msUntilExpiry <= 0) { - // Already expired (e.g. picked up on recovery interval after a long pause). - const success = await triggerAutoPick( - seasonId, - currentTeamId, - currentPickNumber, - shouldAutodraft ? (autodraftSettings ?? null) : null - ); - if (!success) { await pauseDraftOnError(seasonId, currentTeamId); return; } - await _schedulePickForSeason(seasonId); - return; - } - - const timeout = setTimeout(async () => { - pickTimeouts.delete(seasonId); - logger.log( - `[Timer] ⚠️ Timer expired for team ${currentTeamId} in season ${seasonId} (pick ${currentPickNumber})` - ); - const success = await triggerAutoPick( - seasonId, - currentTeamId, - currentPickNumber, - shouldAutodraft ? (autodraftSettings ?? null) : null - ); - if (!success) { - await pauseDraftOnError(seasonId, currentTeamId); - return; - } - await schedulePickForSeason(seasonId); - }, msUntilExpiry); - - pickTimeouts.set(seasonId, timeout); + logger.error(`[Timer] Hit max consecutive auto-picks (${MAX_CONSECUTIVE_AUTOPICKS}) for season ${seasonId}`); } async function schedulePickForSeason(seasonId: string): Promise { @@ -375,12 +343,13 @@ async function scheduleAllActiveDrafts(): Promise { }); // Evict caches for seasons no longer drafting. + // Union both cache key sets so overnight-pause entries populated by socket.ts + // (without a corresponding draftSlotsCache entry) are also evicted. const activeIds = new Set(activeDrafts.map((s) => s.id)); - for (const id of draftSlotsCache.keys()) { - if (!activeIds.has(id)) draftSlotsCache.delete(id); - } - for (const id of teamTimezoneCache.keys()) { - if (!activeIds.has(id)) teamTimezoneCache.delete(id); + const staleIds = new Set([...draftSlotsCache.keys(), ...overnightPauseCacheKeys()].filter((id) => !activeIds.has(id))); + for (const id of staleIds) { + draftSlotsCache.delete(id); + evictOvernightPauseCache(id); } for (const season of activeDrafts) { @@ -424,7 +393,7 @@ export function stopDraftTimerSystem(): void { overnightResumeTimeouts.clear(); schedulingInProgress.clear(); draftSlotsCache.clear(); - teamTimezoneCache.clear(); + clearAllOvernightPauseCaches(); logger.log("[Timer] Draft timer system stopped"); } @@ -435,6 +404,46 @@ export function stopDraftTimerSystem(): void { export async function rescheduleTimer(seasonId: string): Promise { cancelPickTimeout(seasonId); cancelOvernightResumeTimeout(seasonId); - draftSlotsCache.delete(seasonId); // pick may have changed the draft order state await schedulePickForSeason(seasonId); } + +/** + * Called by the pause route. Cancels the in-memory timeout and snapshots the + * remaining time into timeRemaining so resume starts from exactly where it left off. + */ +export async function onDraftPaused(seasonId: string): Promise { + 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 { + 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)); +} -- 2.45.3