## Summary - **Timer bank broadcasts**: emit `timer-bank-updated` after every pick so all clients immediately see the updated bank instead of waiting for the next `timer-pick-started` - **Increment accuracy**: capture `pickMadeAt` at route entry (before auth/DB overhead) and use `Math.ceil` so credited seconds always match the client countdown display - **Race condition fix**: hold `schedulingInProgress` lock for the full timer callback to prevent the recovery interval from scheduling a duplicate timeout mid-pick - **force-autopick fix**: call `rescheduleTimer` so the next team's clock starts immediately instead of waiting for the old timeout to naturally expire - **adjust-time-bank fix**: 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 - **New socket events**: `timer-pick-started`, `timer-overnight-paused`, `timer-bank-updated` with full type definitions; removed dead `timer-update` event - **Reconnect sync**: `draft-state-sync` now includes `expiresAt` for the active timer and `isOvernightPause` state so reconnecting clients see accurate countdown and pause banner immediately without a page reload - **Room closure countdown**: capture client-side timestamp when draft completes so the "Room closes in X" countdown actually ticks down before the loader revalidates with `draftCompletedAt` - **Countdown interval**: run at 500ms with `Math.ceil` to prevent skipped seconds under event loop pressure - **Overnight pause UX**: `canPick` only blocks on commissioner pause — overnight pause freezes the timer but the on-clock player can still pick early - **Overnight pause refactor**: extract `checkOvernightPause` to `server/overnight-pause-check.ts`, breaking the `timer↔socket` circular import and sharing the timezone cache across both callers with correct eviction - **PostgreSQL type fix**: cast `varchar` owner ID to `uuid` in `getTeamTimezone` join ## Test plan - [ ] Manual pick: all clients see bank increment immediately after pick - [ ] Timeout pick: all clients see bank update (0 → increment); next clock starts within ~1s - [ ] Force-autopick: next team's clock starts immediately; no "Pick already made" log - [ ] Force-manual-pick: all clients see bank increment - [ ] Pause while clock running: countdown freezes on all clients - [ ] Resume: clock continues from frozen value - [ ] adjust-time-bank on on-clock team: countdown shifts immediately - [ ] adjust-time-bank to zero: returns 400 error - [ ] Reconnect (socket disconnect/connect): countdown resumes for correct team - [ ] Hard refresh mid-draft: on-clock indicator and countdown correct immediately - [ ] Draft complete: "Room closes in X" counts down - [ ] Overnight pause: banner shows, pick buttons still enabled, timer frozen - [ ] `npm run test:run` — all 158 files / 2351 tests pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com> Reviewed-on: #72
342 lines
14 KiB
TypeScript
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]);
|
|
}
|