brackt/app/hooks/useDraftSocketEvents.ts
Chris Parsons 3611f5620e
Show "Syncing Draft State..." overlay during draft reconnection (#361)
* Show "Syncing Draft State..." overlay during draft reconnection

When a user reconnects to a draft room (socket reconnect, tab visibility
return, etc.), there was a gap between when the socket connected and when
the full draft state sync arrived. The ConnectionOverlay vanished instantly
on reconnect, showing potentially stale data.

Add an isSyncing flag that keeps the overlay visible with "Syncing Draft
State..." messaging until the actual data arrives via socket or HTTP
revalidation. A 5-second safety timeout prevents the overlay from getting
stuck.

Fixes #78

* Add team names utility tests
2026-04-30 20:33:00 -07:00

280 lines
12 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>;
userAutodraftRef: MutableRefObject<{ isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean }>;
// 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;
setIsOvernightPause: (value: boolean) => void;
setOvernightResumesAt: (value: Date | null) => void;
setWatchedParticipantIds: (value: Set<string>) => void;
setRoomClosed: (value: boolean) => void;
setIsSyncing: (value: boolean) => void;
}
export function useDraftSocketEvents({
on,
off,
socketVersion,
userTeam,
draftSlots,
leagueName,
isRevalidatingRef,
pendingPicksDuringRevalidationRef,
pendingQueueMutationsRef,
userAutodraftRef,
sendNotificationRef,
notificationsModeRef,
setPicks,
setCurrentPick,
setIsPaused,
setIsDraftComplete,
setAutodraftStatus,
setUserAutodraft,
setConnectedTeams,
setQueue,
setTeamTimers,
setIsOvernightPause,
setOvernightResumesAt,
setWatchedParticipantIds,
setRoomClosed,
setIsSyncing,
}: UseDraftSocketEventsParams) {
useEffect(() => {
type PickMadePayload = {
pick: DraftPick & { team?: { id?: string; name?: string }; participant?: { name?: string } };
nextPickNumber: number;
isDraftComplete?: boolean;
};
const handlePickMade = (data: PickMadePayload) => {
if (isRevalidatingRef.current) {
pendingPicksDuringRevalidationRef.current.push(data.pick);
} else {
setPicks((prev) => [...prev, data.pick]);
setCurrentPick(data.nextPickNumber);
}
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 handleTimerUpdate = (data: {
teamId: string;
timeRemaining: number;
currentPickNumber: number | null;
overnightPauseActive?: boolean;
resumesAtUTC?: number;
}) => {
setTeamTimers((prev) => {
if (prev[data.teamId] === data.timeRemaining) return prev;
return { ...prev, [data.teamId]: data.timeRemaining };
});
if (data.currentPickNumber !== null) {
setCurrentPick(data.currentPickNumber);
}
const pauseActive = data.overnightPauseActive ?? false;
setIsOvernightPause(pauseActive);
setOvernightResumesAt(pauseActive && data.resumesAtUTC ? new Date(data.resumesAtUTC) : null);
};
const handleDraftPaused = () => setIsPaused(true);
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";
}) => {
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 {
const prev = userAutodraftRef.current;
if (!data.isEnabled && prev.isEnabled && prev.queueOnly && prev.mode === "while_on") {
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(false);
};
const handleDraftStateSync = (data: {
picks: DraftPicks;
currentPickNumber: number;
isPaused: boolean;
status: string;
timers?: Array<{ teamId: string; timeRemaining: 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;
});
}
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);
};
on("pick-made", handlePickMade as (data: unknown) => void);
on("timer-update", handleTimerUpdate 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);
return () => {
off("pick-made", handlePickMade as (data: unknown) => void);
off("timer-update", handleTimerUpdate 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);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [on, off, socketVersion]);
}