From a91c1083936d0e24f89847513c144f71777e5c57 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Sat, 6 Jun 2026 00:19:02 -0700 Subject: [PATCH 1/2] Fix draft timer display drifting due to server-client clock skew MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The countdown display used data.expiresAt (a server-side Unix timestamp) subtracted from client Date.now(). When the server clock is ahead of the client by ~2-3s (observed in production), the display inflated by that delta — causing 2:02 instead of 2:00 on pick start, short-crediting the chess clock increment, and the commissioner's adjust-time-bank dialog disagreeing with the visible countdown. Fix: replace all server expiresAt timestamps with a client-local expiry computed from the server-provided timeRemaining integer (Date.now() + timeRemaining * 1000). The skew then cancels algebraically: server_remaining = server_expiresAt - pickMadeAt = (client_schedule + Δ + bank*1000) - (client_pick + Δ) = client_expiresAt - client_pick ✓ Three sites updated: handleTimerPickStarted, handleDraftStateSync (reconnect), and the initial useState seed from loader data. Co-Authored-By: Claude Sonnet 4.6 --- app/hooks/useDraftSocketEvents.ts | 6 +++--- app/routes/leagues/$leagueId.draft.$seasonId.tsx | 14 ++++++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/app/hooks/useDraftSocketEvents.ts b/app/hooks/useDraftSocketEvents.ts index 30f7c88..be81cd7 100644 --- a/app/hooks/useDraftSocketEvents.ts +++ b/app/hooks/useDraftSocketEvents.ts @@ -121,7 +121,7 @@ export function useDraftSocketEvents({ }) => { setCurrentPick(data.pickNumber); setTeamTimers((prev) => ({ ...prev, [data.teamId]: data.timeRemaining })); - setPickTimerExpiresAt({ teamId: data.teamId, expiresAt: data.expiresAt }); + setPickTimerExpiresAt({ teamId: data.teamId, expiresAt: Date.now() + data.timeRemaining * 1000 }); setIsOvernightPause(false); setOvernightResumesAt(null); }; @@ -248,8 +248,8 @@ export function useDraftSocketEvents({ // 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 }); + if (activeTimer) { + setPickTimerExpiresAt({ teamId: activeTimer.teamId, expiresAt: now + activeTimer.timeRemaining * 1000 }); } else { setPickTimerExpiresAt(null); } diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 455ff61..c00931d 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -111,6 +111,7 @@ export async function loader(args: Route.LoaderArgs) { getSeasonTimers(seasonId), getSeasonAutodraftSettings(seasonId), ]); + const loaderTimerNow = Date.now(); const userQueue = userTeam ? await getTeamQueue(userTeam.id).catch((err) => { @@ -177,6 +178,7 @@ export async function loader(args: Route.LoaderArgs) { userQueue, userWatchlist: userWatchlist.map((w) => w.participantId), timers, + loaderTimerNow, autodraftSettings, userAutodraftSettings, isCommissioner: !!isCommissioner, @@ -196,6 +198,7 @@ export default function DraftRoom() { userQueue, userWatchlist, timers, + loaderTimerNow, autodraftSettings, userAutodraftSettings, isCommissioner, @@ -500,11 +503,14 @@ export default function DraftRoom() { }, []); useEffect(() => () => { animationTimersRef.current.forEach(clearTimeout); }, []); - // Client-side countdown: updated every second from the server-provided expiresAt timestamp. + // Client-side countdown: always anchored to the client clock (not the server timestamp) to avoid skew. const [pickTimerExpiresAt, setPickTimerExpiresAt] = useState<{ teamId: string; expiresAt: number } | null>(() => { - // Seed from initial loader data if a timer is already running. - const active = timers.find((t) => t.picksExpiresAt && t.picksExpiresAt.getTime() > Date.now()); - return active ? { teamId: active.teamId, expiresAt: active.picksExpiresAt?.getTime() ?? 0 } : null; + // loaderTimerNow is server-side Date.now() — subtract from server expiresAt to get ms remaining + // without skew, then re-anchor to client clock. + const active = timers.find((t) => t.picksExpiresAt && t.picksExpiresAt.getTime() > loaderTimerNow); + if (!active) return null; + const secondsRemaining = Math.ceil((active.picksExpiresAt.getTime() - loaderTimerNow) / 1000); + return { teamId: active.teamId, expiresAt: Date.now() + secondsRemaining * 1000 }; }); useEffect(() => { -- 2.45.3 From a4317815f73201c65eac0b0af5da37adfc2bd9be Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Sat, 6 Jun 2026 00:25:23 -0700 Subject: [PATCH 2/2] Fix TS error: narrow picksExpiresAt via optional chain guard Co-Authored-By: Claude Sonnet 4.6 --- app/routes/leagues/$leagueId.draft.$seasonId.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index c00931d..6c740b9 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -508,7 +508,7 @@ export default function DraftRoom() { // loaderTimerNow is server-side Date.now() — subtract from server expiresAt to get ms remaining // without skew, then re-anchor to client clock. const active = timers.find((t) => t.picksExpiresAt && t.picksExpiresAt.getTime() > loaderTimerNow); - if (!active) return null; + if (!active?.picksExpiresAt) return null; const secondsRemaining = Math.ceil((active.picksExpiresAt.getTime() - loaderTimerNow) / 1000); return { teamId: active.teamId, expiresAt: Date.now() + secondsRemaining * 1000 }; }); -- 2.45.3