From 677b8ce816dc958bb2205f5abb579a7cc6623a58 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Sat, 6 Jun 2026 08:52:04 -0700 Subject: [PATCH] Fix three timer-pause gaps found in code review - pauseDraftOnError now calls onDraftPaused() before emitting so the error-path pause carries the same snapshotted timer data as the manual-pause route (fixes inconsistency between the two paths) - Parallelize DB updates in onDraftPaused with Promise.all instead of sequential awaits - Extract clientExpiresAt() helper to draft-timer.ts and use it in all three places that re-anchor timeRemaining to the client clock, replacing duplicated Date.now() + timeRemaining * 1000 expressions Co-Authored-By: Claude Sonnet 4.6 --- app/hooks/useDraftSocketEvents.ts | 14 +++++++--- app/lib/draft-timer.ts | 9 +++++++ app/routes/api/draft.pause.ts | 6 +++-- .../leagues/$leagueId.draft.$seasonId.tsx | 4 +-- server/socket.ts | 2 +- server/timer.ts | 26 ++++++++++++------- 6 files changed, 43 insertions(+), 18 deletions(-) diff --git a/app/hooks/useDraftSocketEvents.ts b/app/hooks/useDraftSocketEvents.ts index be81cd7..0b3b2f4 100644 --- a/app/hooks/useDraftSocketEvents.ts +++ b/app/hooks/useDraftSocketEvents.ts @@ -1,6 +1,7 @@ import { useEffect, type MutableRefObject, type RefObject } from "react"; import { toast } from "sonner"; import { getTeamForPick } from "~/lib/draft-order"; +import { clientExpiresAt } from "~/lib/draft-timer"; import { getAutodraftLabel } from "~/components/AutodraftSettings"; import type { getDraftPicksForSeason } from "~/models/draft-pick"; import type { findDraftSlotsBySeasonId } from "~/models/draft-slot"; @@ -121,7 +122,7 @@ export function useDraftSocketEvents({ }) => { setCurrentPick(data.pickNumber); setTeamTimers((prev) => ({ ...prev, [data.teamId]: data.timeRemaining })); - setPickTimerExpiresAt({ teamId: data.teamId, expiresAt: Date.now() + data.timeRemaining * 1000 }); + setPickTimerExpiresAt({ teamId: data.teamId, expiresAt: clientExpiresAt(data.timeRemaining) }); setIsOvernightPause(false); setOvernightResumesAt(null); }; @@ -140,9 +141,16 @@ export function useDraftSocketEvents({ setOvernightResumesAt(data.resumesAtUTC ? new Date(data.resumesAtUTC) : null); }; - const handleDraftPaused = () => { + const handleDraftPaused = (data: { seasonId: string; paused: boolean; timers?: Array<{ teamId: string; timeRemaining: number }> }) => { setIsPaused(true); setPickTimerExpiresAt(null); + if (data.timers?.length) { + setTeamTimers((prev) => { + const updated = { ...prev }; + data.timers?.forEach((t) => { updated[t.teamId] = t.timeRemaining; }); + return updated; + }); + } }; const handleDraftResumed = () => setIsPaused(false); const handleDraftCompleted = () => setIsDraftComplete(true); @@ -249,7 +257,7 @@ export function useDraftSocketEvents({ const now = Date.now(); const activeTimer = data.timers.find((t) => t.expiresAt !== undefined && t.expiresAt > now); if (activeTimer) { - setPickTimerExpiresAt({ teamId: activeTimer.teamId, expiresAt: now + activeTimer.timeRemaining * 1000 }); + setPickTimerExpiresAt({ teamId: activeTimer.teamId, expiresAt: clientExpiresAt(activeTimer.timeRemaining) }); } else { setPickTimerExpiresAt(null); } diff --git a/app/lib/draft-timer.ts b/app/lib/draft-timer.ts index 10b2d5a..68bea2d 100644 --- a/app/lib/draft-timer.ts +++ b/app/lib/draft-timer.ts @@ -113,6 +113,15 @@ export function getInitialDraftSpeed(season: DraftSpeedSource): string { return "standard"; } +/** + * Anchors a server-provided remaining-seconds value to the local client clock. + * Use this everywhere we convert timeRemaining → an expiresAt timestamp so the + * logic stays consistent across event handlers and initial state. + */ +export function clientExpiresAt(timeRemaining: number): number { + return Date.now() + timeRemaining * 1000; +} + /** * Returns the Tailwind colour class(es) for a timer value. * diff --git a/app/routes/api/draft.pause.ts b/app/routes/api/draft.pause.ts index fe01c51..004eee0 100644 --- a/app/routes/api/draft.pause.ts +++ b/app/routes/api/draft.pause.ts @@ -67,17 +67,19 @@ export async function action(args: ActionFunctionArgs) { }); // Cancel the in-memory timeout and snapshot remaining time so resume is accurate. + let snapshots: Array<{ teamId: string; timeRemaining: number }> = []; try { - await onDraftPaused(seasonId); + snapshots = await onDraftPaused(seasonId); } catch (err) { logger.error("[Pause] onDraftPaused failed:", err); } - // Emit socket event + // Emit socket event with snapshotted timer values so clients sync immediately. try { getSocketIO().to(`draft-${seasonId}`).emit("draft-paused", { seasonId, paused: true, + timers: snapshots, }); } catch (error) { logger.error("Socket.IO error:", error); diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 6c740b9..5161aaf 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -42,7 +42,7 @@ import { useDraftNotifications } from "~/hooks/useDraftNotifications"; import { NotificationSettings } from "~/components/NotificationSettings"; import { AutodraftBadgeWithPopover, AutodraftSettings } from "~/components/AutodraftSettings"; import { toast } from "sonner"; -import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer"; +import { clientExpiresAt, formatClockTime, getTimerColorClass } from "~/lib/draft-timer"; import { Users, LayoutGrid, ListChecks, Settings, ListOrdered } from "lucide-react"; import type { Route } from "./+types/$leagueId.draft.$seasonId"; @@ -510,7 +510,7 @@ export default function DraftRoom() { const active = timers.find((t) => t.picksExpiresAt && t.picksExpiresAt.getTime() > loaderTimerNow); if (!active?.picksExpiresAt) return null; const secondsRemaining = Math.ceil((active.picksExpiresAt.getTime() - loaderTimerNow) / 1000); - return { teamId: active.teamId, expiresAt: Date.now() + secondsRemaining * 1000 }; + return { teamId: active.teamId, expiresAt: clientExpiresAt(secondsRemaining) }; }); useEffect(() => { diff --git a/server/socket.ts b/server/socket.ts index 91bdab6..283b5a4 100644 --- a/server/socket.ts +++ b/server/socket.ts @@ -44,7 +44,7 @@ interface ServerToClientEvents { "team-connected": (data: { teamId: string }) => void; "team-disconnected": (data: { teamId: string }) => void; "connected-teams-list": (data: { teamIds: string[] }) => void; - "draft-paused": (data: { seasonId: string; paused: boolean }) => void; + "draft-paused": (data: { seasonId: string; paused: boolean; timers?: Array<{ teamId: string; timeRemaining: number }> }) => void; "draft-resumed": (data: { seasonId: string; paused: boolean }) => void; "participant-removed-from-queues": (data: { participantId: string }) => void; "queue-updated": (data: { queue: Array<{ id: string; teamId: string; seasonId: string; participantId: string; queuePosition: number }> }) => void; diff --git a/server/timer.ts b/server/timer.ts index 98f079f..15c7f79 100644 --- a/server/timer.ts +++ b/server/timer.ts @@ -84,8 +84,9 @@ function cancelOvernightResumeTimeout(seasonId: string): void { 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)); + const timers = await onDraftPaused(seasonId); try { - getSocketIO().to(`draft-${seasonId}`).emit("draft-paused", { seasonId, paused: true }); + getSocketIO().to(`draft-${seasonId}`).emit("draft-paused", { seasonId, paused: true, timers }); } catch (err) { logger.error("[Timer] Failed to emit draft-paused:", err); } @@ -411,7 +412,7 @@ export async function rescheduleTimer(seasonId: string): Promise { * 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 { +export async function onDraftPaused(seasonId: string): Promise> { cancelPickTimeout(seasonId); cancelOvernightResumeTimeout(seasonId); @@ -422,14 +423,19 @@ export async function onDraftPaused(seasonId: string): Promise { 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)); - } + const snapshots = await Promise.all( + activeTimers + .filter((t) => t.picksExpiresAt !== null) + .map(async (timer) => { + const remaining = Math.max(0, Math.floor(((timer.picksExpiresAt?.getTime() ?? now) - now) / 1000)); + await db + .update(schema.draftTimers) + .set({ timeRemaining: remaining, picksExpiresAt: null, picksStartedAt: null, updatedAt: new Date() }) + .where(eq(schema.draftTimers.id, timer.id)); + return { teamId: timer.teamId, timeRemaining: remaining }; + }) + ); + return snapshots; } /** -- 2.45.3