brackt/docs/agents/deadline-based-timer.md
chrisp f96c8f5244
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m40s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m26s
🚀 Deploy / 🐳 Build (push) Successful in 1m14s
🚀 Deploy / 🚀 Deploy (push) Successful in 12s
Fix draft timer: broadcasts, increments, reconnect sync, overnight pause (#72)
## Summary

- **Timer bank broadcasts**: emit `timer-bank-updated` after every pick so all clients immediately see the updated bank instead of waiting for the next `timer-pick-started`
- **Increment accuracy**: capture `pickMadeAt` at route entry (before auth/DB overhead) and use `Math.ceil` so credited seconds always match the client countdown display
- **Race condition fix**: hold `schedulingInProgress` lock for the full timer callback to prevent the recovery interval from scheduling a duplicate timeout mid-pick
- **force-autopick fix**: call `rescheduleTimer` so the next team's clock starts immediately instead of waiting for the old timeout to naturally expire
- **adjust-time-bank fix**: for on-clock teams, shift `picksExpiresAt` by the adjustment and reschedule so the client countdown updates; block adjustments that would reduce the bank to zero
- **New socket events**: `timer-pick-started`, `timer-overnight-paused`, `timer-bank-updated` with full type definitions; removed dead `timer-update` event
- **Reconnect sync**: `draft-state-sync` now includes `expiresAt` for the active timer and `isOvernightPause` state so reconnecting clients see accurate countdown and pause banner immediately without a page reload
- **Room closure countdown**: capture client-side timestamp when draft completes so the "Room closes in X" countdown actually ticks down before the loader revalidates with `draftCompletedAt`
- **Countdown interval**: run at 500ms with `Math.ceil` to prevent skipped seconds under event loop pressure
- **Overnight pause UX**: `canPick` only blocks on commissioner pause — overnight pause freezes the timer but the on-clock player can still pick early
- **Overnight pause refactor**: extract `checkOvernightPause` to `server/overnight-pause-check.ts`, breaking the `timer↔socket` circular import and sharing the timezone cache across both callers with correct eviction
- **PostgreSQL type fix**: cast `varchar` owner ID to `uuid` in `getTeamTimezone` join

## Test plan

- [ ] Manual pick: all clients see bank increment immediately after pick
- [ ] Timeout pick: all clients see bank update (0 → increment); next clock starts within ~1s
- [ ] Force-autopick: next team's clock starts immediately; no "Pick already made" log
- [ ] Force-manual-pick: all clients see bank increment
- [ ] Pause while clock running: countdown freezes on all clients
- [ ] Resume: clock continues from frozen value
- [ ] adjust-time-bank on on-clock team: countdown shifts immediately
- [ ] adjust-time-bank to zero: returns 400 error
- [ ] Reconnect (socket disconnect/connect): countdown resumes for correct team
- [ ] Hard refresh mid-draft: on-clock indicator and countdown correct immediately
- [ ] Draft complete: "Room closes in X" counts down
- [ ] Overnight pause: banner shows, pick buttons still enabled, timer frozen
- [ ] `npm run test:run` — all 158 files / 2351 tests pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #72
2026-06-06 05:57:46 +00:00

14 KiB
Raw Permalink Blame History

Deadline-based draft timer — implementation guide

Companion to docs/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:

ALTER TABLE seasons ADD COLUMN pick_deadline_at timestamptz;

Drizzle:

// 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.

// 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

// server/scheduler.ts  (rename target for the rewritten server/timer.ts)

const timers = new Map<string, { deadline: Date; handle: NodeJS.Timeout }>();

export function scheduleDeadline(seasonId: string, deadline: Date): void {
  const existing = timers.get(seasonId);
  if (existing) clearTimeout(existing.handle);

  const delay = Math.max(0, deadline.getTime() - Date.now());
  const handle = setTimeout(() => fireDeadline(seasonId, deadline), delay);
  timers.set(seasonId, { deadline, handle });
}

export function cancelDeadline(seasonId: string): void {
  const existing = timers.get(seasonId);
  if (!existing) return;
  clearTimeout(existing.handle);
  timers.delete(seasonId);
}

async function fireDeadline(seasonId: string, expected: Date): Promise<void> {
  // Re-read state — the closure may be stale.
  const season = await db.query.seasons.findFirst({
    where: eq(schema.seasons.id, seasonId),
  });
  if (!season) return;                                  // gone
  if (season.status !== "draft") return;                // completed/cancelled
  if (season.draftPaused) return;                       // paused mid-flight
  if (!season.pickDeadlineAt) return;                   // paused or cleared
  if (season.pickDeadlineAt.getTime() !== expected.getTime()) {
    // Someone changed the deadline (admin adjust, etc). Reschedule, don't fire.
    return scheduleDeadline(seasonId, season.pickDeadlineAt);
  }
  // Deadline still valid — run the auto-pick.
  await runAutoPickForCurrentTeam(seasonId);
}

On boot, hydrate from the DB:

export async function bootstrapScheduler(): Promise<void> {
  const active = await db.query.seasons.findMany({
    where: and(
      eq(schema.seasons.status, "draft"),
      isNotNull(schema.seasons.pickDeadlineAt),
    ),
  });
  for (const s of active) {
    scheduleDeadline(s.id, s.pickDeadlineAt!);
  }
}

Already-expired deadlines fire immediately (Math.max(0, …)setTimeout(0)), and executeAutoPick's existing idempotency ((seasonId, pickNumber) uniqueness with ON CONFLICT DO NOTHING in app/models/draft-utils.ts:629) protects against duplicate firing if the crash happened mid-pick.

Callsite migration

Five sites need to call setNextPickDeadline after this change:

1. Draft auto-start — app/services/draft-autostart.ts

Today: deletes timer rows, re-seeds them with timeRemaining: initialTime. After the draft transitions to status='draft', call the helper for pick #1's team:

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:

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:

// 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:

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:

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:

// 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 setTimeouts 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 (3060s) 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.