brackt/app/hooks/useDraftSocketEvents.ts
Chris Parsons 190a6e1fe7
Add electric flash + fade animation for draft picks, fixes #381 (#407)
When a pick is made the picked player's row flashes teal (--electric)
then fades out over 750ms. Works correctly with the TanStack Virtual
list by keeping the row in filteredParticipants during the animation
via animatingOutParticipantIds, then removing it after 650ms.

Key design decisions:
- Animation state lives in AvailableParticipantsSection (internal
  useState + timers); the draft room passes a pickAnimationSignal
  {id, seq} rather than the Set, so memo only breaks on each pick,
  not on the 650ms cleanup
- displayDrafted freezes row content (badges, buttons) during the
  animation to prevent height shifts
- Per-ID independent timers via Map ref support rapid autodraft picks
  running concurrently without cancellation
- Timers are cleared on unmount in both the draft room and component
- prefers-reduced-motion: skips the color flash, plain opacity fade only
- Removes "Draft Grid" heading from DraftGridSection (visual cleanup)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 23:17:29 -07:00

301 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;
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,
userAutodraftRef,
sendNotificationRef,
notificationsModeRef,
setPicks,
setCurrentPick,
setIsPaused,
setIsDraftComplete,
setAutodraftStatus,
setUserAutodraft,
setConnectedTeams,
setQueue,
setTeamTimers,
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) => {
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 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);
};
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-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);
on("brackt-evs-updated", handleBracktEvsUpdated 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);
off("brackt-evs-updated", handleBracktEvsUpdated as (data: unknown) => void);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [on, off, socketVersion]);
}