Fix draft timer display drifting due to server-client clock skew
Some checks failed
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m35s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Failing after 1m15s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped

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 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-06-06 00:19:02 -07:00
parent 60aa8a8c9a
commit a91c108393
2 changed files with 13 additions and 7 deletions

View file

@ -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);
}

View file

@ -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(() => {