Fix three timer-pause gaps found in code review
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m34s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m19s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped

- 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 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-06-06 08:52:04 -07:00
parent 079ecec129
commit 677b8ce816
6 changed files with 43 additions and 18 deletions

View file

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

View file

@ -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.
*

View file

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

View file

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

View file

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

View file

@ -84,8 +84,9 @@ function cancelOvernightResumeTimeout(seasonId: string): void {
async function pauseDraftOnError(seasonId: string, teamId: string): Promise<void> {
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<void> {
* 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<void> {
export async function onDraftPaused(seasonId: string): Promise<Array<{ teamId: string; timeRemaining: number }>> {
cancelPickTimeout(seasonId);
cancelOvernightResumeTimeout(seasonId);
@ -422,14 +423,19 @@ export async function onDraftPaused(seasonId: string): Promise<void> {
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;
}
/**