brackt/app/hooks/useDraftSocketEvents.ts
Chris Parsons 46f8552f60
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 2m39s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m24s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Fix draft timer bugs: broadcasts, increments, reconnect sync, and overnight pause
- Broadcast timer-bank-updated after every pick so all connected clients
  immediately see the updated time bank (was only visible on next timer-pick-started)
- Capture pickMadeAt at route entry (before auth/DB overhead) and use Math.ceil
  so credited seconds always match the client countdown display
- Clear picksExpiresAt on every pick so _schedulePickForSeason starts fresh
- Hold schedulingInProgress lock for full timer callback to prevent the recovery
  interval from scheduling a duplicate timeout mid-pick
- Fix force-autopick route: call rescheduleTimer so the next team's clock
  starts immediately instead of waiting for the old timeout to fire naturally
- Fix draft.adjust-time-bank for on-clock teams: shift picksExpiresAt by the
  adjustment and reschedule, so the client countdown updates; block adjustments
  that would reduce the bank to zero
- Add timer-pick-started / timer-overnight-paused / timer-bank-updated socket
  events with full type definitions; replace dead timer-update event
- Fix draft-state-sync to include expiresAt for the active timer and
  isOvernightPause state so reconnecting clients see accurate countdown and
  pause banner immediately
- Fix room-closure countdown: capture client-side timestamp when draft completes
  so countdown runs even before the loader revalidates with draftCompletedAt
- Run countdown interval at 500ms with Math.ceil to prevent skipped seconds
- Add draft-started socket handler to transition pre-draft UI without a refresh
- Fix overnight pause: canPick only blocks on commissioner pause, not overnight
  pause (timer freezes but player can still pick early)
- Extract checkOvernightPause to server/overnight-pause-check.ts, breaking the
  timer↔socket circular import and ensuring the timezone cache is shared and
  evicted correctly across both callers
- Fix PostgreSQL varchar=uuid type mismatch in getTeamTimezone join

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 22:42:19 -07:00

342 lines
14 KiB
TypeScript

import { useEffect, type MutableRefObject, type RefObject } from "react";
import { toast } from "sonner";
import { getTeamForPick } from "~/lib/draft-order";
import { getAutodraftLabel } from "~/components/AutodraftSettings";
import type { getDraftPicksForSeason } from "~/models/draft-pick";
import type { findDraftSlotsBySeasonId } from "~/models/draft-slot";
import type { QueueItem, AutodraftStatusEntry } from "~/hooks/useDraftRoomState";
type DraftPicks = Awaited<ReturnType<typeof getDraftPicksForSeason>>;
type DraftSlots = Awaited<ReturnType<typeof findDraftSlotsBySeasonId>>;
type DraftPick = DraftPicks[number];
interface UseDraftSocketEventsParams {
on: (event: string, handler: (data: unknown) => void) => void;
off: (event: string, handler: (data: unknown) => void) => void;
socketVersion: number;
// Loader-derived, stable for lifetime of page load
userTeam: { id: string } | undefined;
draftSlots: DraftSlots;
leagueName: string;
// Refs from useDraftAuthRecovery
isRevalidatingRef: RefObject<boolean>;
pendingPicksDuringRevalidationRef: MutableRefObject<DraftPicks>;
pendingQueueMutationsRef: MutableRefObject<number>;
// Notification refs from the component
sendNotificationRef: MutableRefObject<(title: string, body: string) => void>;
notificationsModeRef: MutableRefObject<string>;
// State setters
setPicks: (fn: (prev: DraftPicks) => DraftPicks) => void;
setCurrentPick: (pick: number) => void;
setIsPaused: (paused: boolean) => void;
setIsDraftComplete: (complete: boolean) => void;
setAutodraftStatus: (fn: (prev: Record<string, AutodraftStatusEntry>) => Record<string, AutodraftStatusEntry>) => void;
setUserAutodraft: (value: { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean }) => void;
setConnectedTeams: (fn: (prev: Set<string>) => Set<string>) => void;
setQueue: (fn: (prev: QueueItem[]) => QueueItem[]) => void;
setTeamTimers: (fn: (prev: Record<string, number>) => Record<string, number>) => void;
setPickTimerExpiresAt: (state: { teamId: string; expiresAt: number } | null) => void;
setIsOvernightPause: (value: boolean) => void;
setOvernightResumesAt: (value: Date | null) => void;
setWatchedParticipantIds: (value: Set<string>) => void;
setRoomClosed: (value: boolean) => void;
setIsSyncing: (value: boolean) => void;
setBracktVorps: (fn: (prev: Map<string, number>) => Map<string, number>) => void;
setAnimatingOutParticipantId: (id: string) => void;
}
export function useDraftSocketEvents({
on,
off,
socketVersion,
userTeam,
draftSlots,
leagueName,
isRevalidatingRef,
pendingPicksDuringRevalidationRef,
pendingQueueMutationsRef,
sendNotificationRef,
notificationsModeRef,
setPicks,
setCurrentPick,
setIsPaused,
setIsDraftComplete,
setAutodraftStatus,
setUserAutodraft,
setConnectedTeams,
setQueue,
setTeamTimers,
setPickTimerExpiresAt,
setIsOvernightPause,
setOvernightResumesAt,
setWatchedParticipantIds,
setRoomClosed,
setIsSyncing,
setBracktVorps,
setAnimatingOutParticipantId,
}: UseDraftSocketEventsParams) {
useEffect(() => {
type PickMadePayload = {
pick: DraftPick & { team?: { id?: string; name?: string }; participant?: { name?: string } };
nextPickNumber: number;
isDraftComplete?: boolean;
};
const handlePickMade = (data: PickMadePayload) => {
// Stop any running countdown — timer-pick-started for the next team will restart it.
setPickTimerExpiresAt(null);
if (isRevalidatingRef.current) {
pendingPicksDuringRevalidationRef.current.push(data.pick);
} else {
setPicks((prev) => [...prev, data.pick]);
setCurrentPick(data.nextPickNumber);
if (data.pick?.participant?.id) {
setAnimatingOutParticipantId(data.pick.participant.id);
}
}
if (data.isDraftComplete) return;
const notifTitle = `${leagueName} Draft`;
const nextSlot = getTeamForPick(data.nextPickNumber, draftSlots);
const isNowMyTurn = !!(userTeam && nextSlot && nextSlot.team.id === userTeam.id);
if (isNowMyTurn) {
sendNotificationRef.current(notifTitle, `It's your turn to pick!`);
} else if (
notificationsModeRef.current === "all_picks" &&
data.pick?.team?.id !== userTeam?.id
) {
const pickerName = data.pick?.team?.name || "A team";
const participantName = data.pick?.participant?.name || "a participant";
sendNotificationRef.current(notifTitle, `${pickerName} picked ${participantName}`);
}
};
const handleTimerPickStarted = (data: {
seasonId: string;
teamId: string;
pickNumber: number;
expiresAt: number;
timeRemaining: number;
}) => {
setCurrentPick(data.pickNumber);
setTeamTimers((prev) => ({ ...prev, [data.teamId]: data.timeRemaining }));
setPickTimerExpiresAt({ teamId: data.teamId, expiresAt: data.expiresAt });
setIsOvernightPause(false);
setOvernightResumesAt(null);
};
const handleTimerBankUpdated = (data: { teamId: string; timeRemaining: number }) => {
setTeamTimers((prev) => ({ ...prev, [data.teamId]: data.timeRemaining }));
};
const handleTimerOvernightPaused = (data: {
seasonId: string;
teamId: string;
resumesAtUTC?: number;
}) => {
setPickTimerExpiresAt(null);
setIsOvernightPause(true);
setOvernightResumesAt(data.resumesAtUTC ? new Date(data.resumesAtUTC) : null);
};
const handleDraftPaused = () => {
setIsPaused(true);
setPickTimerExpiresAt(null);
};
const handleDraftResumed = () => setIsPaused(false);
const handleDraftCompleted = () => setIsDraftComplete(true);
const handleAutodraftUpdated = (data: {
teamId: string;
isEnabled: boolean;
mode: "next_pick" | "while_on";
queueOnly: boolean;
source?: "commissioner" | "user";
reason?: "queue_empty" | "pick_complete";
}) => {
setAutodraftStatus((prev) => ({
...prev,
[data.teamId]: { isEnabled: data.isEnabled, mode: data.mode, queueOnly: data.queueOnly },
}));
if (userTeam && data.teamId === userTeam.id) {
if (data.source === "commissioner") {
if (!data.isEnabled) {
toast.info("Commissioner turned off your autodraft");
} else {
toast.info(`Commissioner changed your autodraft to "${getAutodraftLabel(data.isEnabled, data.mode, data.queueOnly)}"`);
}
} else {
if (!data.isEnabled && data.reason === "pick_complete") {
toast.info("Autodraft turned off — next pick complete");
} else if (!data.isEnabled && data.reason === "queue_empty") {
toast.info("Autodraft disabled — your queue is empty");
}
}
setUserAutodraft({ isEnabled: data.isEnabled, mode: data.mode, queueOnly: data.queueOnly });
}
};
const handleTeamConnected = (data: { teamId: string }) => {
setConnectedTeams((prev) => new Set(prev).add(data.teamId));
};
const handleTeamDisconnected = (data: { teamId: string }) => {
setConnectedTeams((prev) => {
const newSet = new Set(prev);
newSet.delete(data.teamId);
return newSet;
});
};
const handleConnectedTeamsList = (data: { teamIds: string[] }) => {
setConnectedTeams(() => new Set(data.teamIds));
};
const handleParticipantRemovedFromQueues = (data: { participantId: string }) => {
setQueue((prev) => prev.filter((item) => item.participantId !== data.participantId));
};
const handleQueueEligibilityPruned = (data: { teamId: string; removedParticipantIds: string[] }) => {
if (data.teamId !== userTeam?.id) return;
const removed = new Set(data.removedParticipantIds);
setQueue((prev) => prev.filter((item) => !removed.has(item.participantId)));
};
const handleQueueUpdated = (data: { queue: QueueItem[] }) => {
if (pendingQueueMutationsRef.current > 0) return;
setQueue(() => data.queue);
};
const handlePickReplaced = (data: { pickNumber: number; pick: DraftPick }) => {
setPicks((prev) => prev.map((p) => (p.pickNumber === data.pickNumber ? data.pick : p)));
};
const handleDraftRolledBack = (data: { pickNumber: number }) => {
setPicks((prev) => prev.filter((p) => p.pickNumber < data.pickNumber));
setCurrentPick(data.pickNumber);
setIsDraftComplete(false);
setIsPaused(true);
setPickTimerExpiresAt(null);
};
const handleDraftStateSync = (data: {
picks: DraftPicks;
currentPickNumber: number;
isPaused: boolean;
status: string;
isOvernightPause?: boolean;
overnightResumesAt?: number;
timers?: Array<{ teamId: string; timeRemaining: number; expiresAt?: number }>;
queue?: QueueItem[];
watchlistParticipantIds?: string[];
}) => {
if (!isRevalidatingRef.current) {
setPicks(() => data.picks);
setCurrentPick(data.currentPickNumber);
setIsPaused(data.isPaused);
setIsDraftComplete(data.status === "active" || data.status === "completed");
if (data.timers) {
setTeamTimers((prev) => {
const updated = { ...prev };
data.timers?.forEach((t) => { updated[t.teamId] = t.timeRemaining; });
return updated;
});
// Restore countdown for the active team. Filter out already-expired timestamps
// 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 });
} else {
setPickTimerExpiresAt(null);
}
}
// Restore overnight-pause state for clients that connect mid-pause.
if (data.isOvernightPause) {
setIsOvernightPause(true);
setOvernightResumesAt(data.overnightResumesAt ? new Date(data.overnightResumesAt) : null);
setPickTimerExpiresAt(null);
} else {
setIsOvernightPause(false);
setOvernightResumesAt(null);
}
if (data.queue) {
const q = data.queue;
setQueue(() => q);
}
if (data.watchlistParticipantIds) {
setWatchedParticipantIds(new Set(data.watchlistParticipantIds));
}
}
setIsSyncing(false);
};
const handleWatchlistUpdated = (data: { participantIds: string[] }) => {
setWatchedParticipantIds(new Set(data.participantIds));
};
const handleDraftRoomClosed = () => {
setRoomClosed(true);
};
const handleBracktEvsUpdated = (data: {
updates: Array<{ participantId: string; vorpValue: number }>;
}) => {
setBracktVorps((prev) => {
const next = new Map(prev);
for (const { participantId, vorpValue } of data.updates) {
next.set(participantId, vorpValue);
}
return next;
});
};
on("pick-made", handlePickMade as (data: unknown) => void);
on("timer-bank-updated", handleTimerBankUpdated as (data: unknown) => void);
on("timer-pick-started", handleTimerPickStarted as (data: unknown) => void);
on("timer-overnight-paused", handleTimerOvernightPaused as (data: unknown) => void);
on("draft-paused", handleDraftPaused as (data: unknown) => void);
on("draft-resumed", handleDraftResumed as (data: unknown) => void);
on("draft-completed", handleDraftCompleted as (data: unknown) => void);
on("autodraft-updated", handleAutodraftUpdated as (data: unknown) => void);
on("team-connected", handleTeamConnected as (data: unknown) => void);
on("team-disconnected", handleTeamDisconnected as (data: unknown) => void);
on("connected-teams-list", handleConnectedTeamsList as (data: unknown) => void);
on("participant-removed-from-queues", handleParticipantRemovedFromQueues as (data: unknown) => void);
on("queue-eligibility-pruned", handleQueueEligibilityPruned as (data: unknown) => void);
on("queue-updated", handleQueueUpdated as (data: unknown) => void);
on("pick-replaced", handlePickReplaced as (data: unknown) => void);
on("draft-rolled-back", handleDraftRolledBack as (data: unknown) => void);
on("draft-state-sync", handleDraftStateSync as (data: unknown) => void);
on("watchlist-updated", handleWatchlistUpdated as (data: unknown) => void);
on("draft-room-closed", handleDraftRoomClosed as (data: unknown) => void);
on("brackt-evs-updated", handleBracktEvsUpdated as (data: unknown) => void);
return () => {
off("pick-made", handlePickMade as (data: unknown) => void);
off("timer-bank-updated", handleTimerBankUpdated as (data: unknown) => void);
off("timer-pick-started", handleTimerPickStarted as (data: unknown) => void);
off("timer-overnight-paused", handleTimerOvernightPaused as (data: unknown) => void);
off("draft-paused", handleDraftPaused as (data: unknown) => void);
off("draft-resumed", handleDraftResumed as (data: unknown) => void);
off("draft-completed", handleDraftCompleted as (data: unknown) => void);
off("autodraft-updated", handleAutodraftUpdated as (data: unknown) => void);
off("team-connected", handleTeamConnected as (data: unknown) => void);
off("team-disconnected", handleTeamDisconnected as (data: unknown) => void);
off("connected-teams-list", handleConnectedTeamsList as (data: unknown) => void);
off("participant-removed-from-queues", handleParticipantRemovedFromQueues as (data: unknown) => void);
off("queue-eligibility-pruned", handleQueueEligibilityPruned as (data: unknown) => void);
off("queue-updated", handleQueueUpdated as (data: unknown) => void);
off("pick-replaced", handlePickReplaced as (data: unknown) => void);
off("draft-rolled-back", handleDraftRolledBack as (data: unknown) => void);
off("draft-state-sync", handleDraftStateSync as (data: unknown) => void);
off("watchlist-updated", handleWatchlistUpdated as (data: unknown) => void);
off("draft-room-closed", handleDraftRoomClosed as (data: unknown) => void);
off("brackt-evs-updated", handleBracktEvsUpdated as (data: unknown) => void);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [on, off, socketVersion]);
}