Merge pull request 'Fix three timer-pause gaps from code review' (#75) from fix/review-timer-pause-gaps into main
Some checks failed
🚀 Deploy / 🧪 Test (push) Failing after 2m32s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m18s
🚀 Deploy / 🐳 Build (push) Has been skipped
🚀 Deploy / 🚀 Deploy (push) Has been skipped

Reviewed-on: #75
This commit is contained in:
chrisp 2026-06-06 15:57:15 +00:00
commit f4bf7ff723
6 changed files with 43 additions and 18 deletions

View file

@ -1,6 +1,7 @@
import { useEffect, type MutableRefObject, type RefObject } from "react"; import { useEffect, type MutableRefObject, type RefObject } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { getTeamForPick } from "~/lib/draft-order"; import { getTeamForPick } from "~/lib/draft-order";
import { clientExpiresAt } from "~/lib/draft-timer";
import { getAutodraftLabel } from "~/components/AutodraftSettings"; import { getAutodraftLabel } from "~/components/AutodraftSettings";
import type { getDraftPicksForSeason } from "~/models/draft-pick"; import type { getDraftPicksForSeason } from "~/models/draft-pick";
import type { findDraftSlotsBySeasonId } from "~/models/draft-slot"; import type { findDraftSlotsBySeasonId } from "~/models/draft-slot";
@ -121,7 +122,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: Date.now() + data.timeRemaining * 1000 }); setPickTimerExpiresAt({ teamId: data.teamId, expiresAt: clientExpiresAt(data.timeRemaining) });
setIsOvernightPause(false); setIsOvernightPause(false);
setOvernightResumesAt(null); setOvernightResumesAt(null);
}; };
@ -140,9 +141,16 @@ export function useDraftSocketEvents({
setOvernightResumesAt(data.resumesAtUTC ? new Date(data.resumesAtUTC) : null); 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); setIsPaused(true);
setPickTimerExpiresAt(null); 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 handleDraftResumed = () => setIsPaused(false);
const handleDraftCompleted = () => setIsDraftComplete(true); const handleDraftCompleted = () => setIsDraftComplete(true);
@ -249,7 +257,7 @@ export function useDraftSocketEvents({
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) { if (activeTimer) {
setPickTimerExpiresAt({ teamId: activeTimer.teamId, expiresAt: now + activeTimer.timeRemaining * 1000 }); setPickTimerExpiresAt({ teamId: activeTimer.teamId, expiresAt: clientExpiresAt(activeTimer.timeRemaining) });
} else { } else {
setPickTimerExpiresAt(null); setPickTimerExpiresAt(null);
} }

View file

@ -113,6 +113,15 @@ export function getInitialDraftSpeed(season: DraftSpeedSource): string {
return "standard"; 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. * 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. // Cancel the in-memory timeout and snapshot remaining time so resume is accurate.
let snapshots: Array<{ teamId: string; timeRemaining: number }> = [];
try { try {
await onDraftPaused(seasonId); snapshots = await onDraftPaused(seasonId);
} catch (err) { } catch (err) {
logger.error("[Pause] onDraftPaused failed:", err); logger.error("[Pause] onDraftPaused failed:", err);
} }
// Emit socket event // Emit socket event with snapshotted timer values so clients sync immediately.
try { try {
getSocketIO().to(`draft-${seasonId}`).emit("draft-paused", { getSocketIO().to(`draft-${seasonId}`).emit("draft-paused", {
seasonId, seasonId,
paused: true, paused: true,
timers: snapshots,
}); });
} catch (error) { } catch (error) {
logger.error("Socket.IO error:", error); logger.error("Socket.IO error:", error);

View file

@ -42,7 +42,7 @@ import { useDraftNotifications } from "~/hooks/useDraftNotifications";
import { NotificationSettings } from "~/components/NotificationSettings"; import { NotificationSettings } from "~/components/NotificationSettings";
import { AutodraftBadgeWithPopover, AutodraftSettings } from "~/components/AutodraftSettings"; import { AutodraftBadgeWithPopover, AutodraftSettings } from "~/components/AutodraftSettings";
import { toast } from "sonner"; 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 { Users, LayoutGrid, ListChecks, Settings, ListOrdered } from "lucide-react";
import type { Route } from "./+types/$leagueId.draft.$seasonId"; 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); const active = timers.find((t) => t.picksExpiresAt && t.picksExpiresAt.getTime() > loaderTimerNow);
if (!active?.picksExpiresAt) return null; if (!active?.picksExpiresAt) return null;
const secondsRemaining = Math.ceil((active.picksExpiresAt.getTime() - loaderTimerNow) / 1000); 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(() => { useEffect(() => {

View file

@ -44,7 +44,7 @@ interface ServerToClientEvents {
"team-connected": (data: { teamId: string }) => void; "team-connected": (data: { teamId: string }) => void;
"team-disconnected": (data: { teamId: string }) => void; "team-disconnected": (data: { teamId: string }) => void;
"connected-teams-list": (data: { teamIds: 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; "draft-resumed": (data: { seasonId: string; paused: boolean }) => void;
"participant-removed-from-queues": (data: { participantId: string }) => 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; "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> { async function pauseDraftOnError(seasonId: string, teamId: string): Promise<void> {
logger.error(`[Timer] Pausing draft ${seasonId} — pick failed for team ${teamId}`); 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)); await db.update(schema.seasons).set({ draftPaused: true }).where(eq(schema.seasons.id, seasonId));
const timers = await onDraftPaused(seasonId);
try { try {
getSocketIO().to(`draft-${seasonId}`).emit("draft-paused", { seasonId, paused: true }); getSocketIO().to(`draft-${seasonId}`).emit("draft-paused", { seasonId, paused: true, timers });
} catch (err) { } catch (err) {
logger.error("[Timer] Failed to emit draft-paused:", 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 * 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. * 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); cancelPickTimeout(seasonId);
cancelOvernightResumeTimeout(seasonId); cancelOvernightResumeTimeout(seasonId);
@ -422,14 +423,19 @@ export async function onDraftPaused(seasonId: string): Promise<void> {
isNotNull(schema.draftTimers.picksExpiresAt), isNotNull(schema.draftTimers.picksExpiresAt),
), ),
}); });
for (const timer of activeTimers) { const snapshots = await Promise.all(
if (!timer.picksExpiresAt) continue; activeTimers
const remaining = Math.max(0, Math.floor((timer.picksExpiresAt.getTime() - now) / 1000)); .filter((t) => t.picksExpiresAt !== null)
await db .map(async (timer) => {
.update(schema.draftTimers) const remaining = Math.max(0, Math.floor(((timer.picksExpiresAt?.getTime() ?? now) - now) / 1000));
.set({ timeRemaining: remaining, picksExpiresAt: null, picksStartedAt: null, updatedAt: new Date() }) await db
.where(eq(schema.draftTimers.id, timer.id)); .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;
} }
/** /**