- Add watchlist feature: eye icon per participant, "Watched Only" filter, DB table + migration, toggle API route, socket sync on reconnect - Make RecentPicksFeed collapsible on mobile participants tab (chevron toggle, defaults expanded) - Add AutodraftSettings to mobile controls tab (was desktop-only) - Pin Pause/Resume Draft and Exit Draft Room buttons to the bottom of the mobile controls tab Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
269 lines
11 KiB
TypeScript
269 lines
11 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;
|
|
}
|
|
|
|
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,
|
|
}: 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));
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleWatchlistUpdated = (data: { participantIds: string[] }) => {
|
|
setWatchedParticipantIds(new Set(data.participantIds));
|
|
};
|
|
|
|
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);
|
|
|
|
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);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [on, off, socketVersion]);
|
|
}
|