Fix draft timer display drifting due to server-client clock skew (#74)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m35s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m23s
🚀 Deploy / 🐳 Build (push) Successful in 1m11s
🚀 Deploy / 🚀 Deploy (push) Successful in 10s

## Summary

- The countdown display used `data.expiresAt` (server-side Unix timestamp) subtracted from `client Date.now()`. When the server clock runs ~2-3s ahead of the client (observed in production), the display was inflated by that delta.
- Fixes three linked symptoms: timer starting at 2:02 instead of 2:00, chess-clock increment crediting 1:54 instead of 1:55, and the commissioner's adjust-time-bank dialog disagreeing with the visible countdown.
- Fix: replace all server `expiresAt` timestamps with a client-local expiry from the server-provided `timeRemaining` integer (`Date.now() + timeRemaining * 1000`). The skew then cancels algebraically when the server computes credit.

## Sites changed

- `handleTimerPickStarted` — fires after every pick (the main fix)
- `handleDraftStateSync` — reconnect path
- `useState` initializer in the draft room — initial page load seed from loader data (uses `loaderTimerNow` server timestamp to compute remaining server-side, then re-anchors to client clock)

## Test plan

- [ ] Start a chess-clock draft (2:00 bank, 15s increment) — timer should show exactly **2:00** when a team goes on the clock
- [ ] Pick at 1:40 — new bank should show **1:55** (100 + 15s)
- [ ] Commissioner adjust-time-bank dialog — displayed remaining should match the countdown
- [ ] Force autopick at any displayed time T — new bank should be T + increment
- [ ] Reconnect mid-pick — timer should resume at the correct remaining time

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

Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #74
This commit is contained in:
chrisp 2026-06-06 07:30:45 +00:00
parent 60aa8a8c9a
commit 079ecec129
2 changed files with 13 additions and 7 deletions

View file

@ -121,7 +121,7 @@ export function useDraftSocketEvents({
}) => { }) => {
setCurrentPick(data.pickNumber); setCurrentPick(data.pickNumber);
setTeamTimers((prev) => ({ ...prev, [data.teamId]: data.timeRemaining })); 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); setIsOvernightPause(false);
setOvernightResumesAt(null); setOvernightResumesAt(null);
}; };
@ -248,8 +248,8 @@ export function useDraftSocketEvents({
// so a reconnect after autopick fired doesn't briefly show a stale countdown. // so a reconnect after autopick fired doesn't briefly show a stale countdown.
const now = Date.now(); const now = Date.now();
const activeTimer = data.timers.find((t) => t.expiresAt !== undefined && t.expiresAt > now); const activeTimer = data.timers.find((t) => t.expiresAt !== undefined && t.expiresAt > now);
if (activeTimer?.expiresAt) { if (activeTimer) {
setPickTimerExpiresAt({ teamId: activeTimer.teamId, expiresAt: activeTimer.expiresAt }); setPickTimerExpiresAt({ teamId: activeTimer.teamId, expiresAt: now + activeTimer.timeRemaining * 1000 });
} else { } else {
setPickTimerExpiresAt(null); setPickTimerExpiresAt(null);
} }

View file

@ -111,6 +111,7 @@ export async function loader(args: Route.LoaderArgs) {
getSeasonTimers(seasonId), getSeasonTimers(seasonId),
getSeasonAutodraftSettings(seasonId), getSeasonAutodraftSettings(seasonId),
]); ]);
const loaderTimerNow = Date.now();
const userQueue = userTeam const userQueue = userTeam
? await getTeamQueue(userTeam.id).catch((err) => { ? await getTeamQueue(userTeam.id).catch((err) => {
@ -177,6 +178,7 @@ export async function loader(args: Route.LoaderArgs) {
userQueue, userQueue,
userWatchlist: userWatchlist.map((w) => w.participantId), userWatchlist: userWatchlist.map((w) => w.participantId),
timers, timers,
loaderTimerNow,
autodraftSettings, autodraftSettings,
userAutodraftSettings, userAutodraftSettings,
isCommissioner: !!isCommissioner, isCommissioner: !!isCommissioner,
@ -196,6 +198,7 @@ export default function DraftRoom() {
userQueue, userQueue,
userWatchlist, userWatchlist,
timers, timers,
loaderTimerNow,
autodraftSettings, autodraftSettings,
userAutodraftSettings, userAutodraftSettings,
isCommissioner, isCommissioner,
@ -500,11 +503,14 @@ export default function DraftRoom() {
}, []); }, []);
useEffect(() => () => { animationTimersRef.current.forEach(clearTimeout); }, []); 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>(() => { const [pickTimerExpiresAt, setPickTimerExpiresAt] = useState<{ teamId: string; expiresAt: number } | null>(() => {
// Seed from initial loader data if a timer is already running. // loaderTimerNow is server-side Date.now() — subtract from server expiresAt to get ms remaining
const active = timers.find((t) => t.picksExpiresAt && t.picksExpiresAt.getTime() > Date.now()); // without skew, then re-anchor to client clock.
return active ? { teamId: active.teamId, expiresAt: active.picksExpiresAt?.getTime() ?? 0 } : null; 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 };
}); });
useEffect(() => { useEffect(() => {