feat: implement real-time queue updates via socket events in queue actions (#63)

This commit is contained in:
Chris Parsons 2026-03-04 21:39:54 -08:00 committed by GitHub
parent 435acdeef0
commit 472ceca92d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 76 additions and 28 deletions

View file

@ -3,6 +3,7 @@ import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { addToQueue, getTeamQueue, isParticipantInQueue } from "~/models/draft-queue"; import { addToQueue, getTeamQueue, isParticipantInQueue } from "~/models/draft-queue";
import { getSocketIO } from "../../../server/socket";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
@ -51,9 +52,9 @@ export async function action(args: ActionFunctionArgs) {
queuePosition, queuePosition,
}); });
// TODO: Emit socket event for real-time update const updatedQueue = await getTeamQueue(teamId as string);
// const io = getSocketIO(); const io = getSocketIO();
// io.to(`draft-${seasonId}`).emit("queue-updated", { teamId, queue: [...currentQueue, queueItem] }); io.to(`team-${teamId}`).emit("queue-updated", { queue: updatedQueue });
return Response.json({ success: true, queueItem }); return Response.json({ success: true, queueItem });
} }

View file

@ -3,6 +3,7 @@ import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { clearTeamQueue } from "~/models/draft-queue"; import { clearTeamQueue } from "~/models/draft-queue";
import { getSocketIO } from "../../../server/socket";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
@ -34,9 +35,8 @@ export async function action(args: ActionFunctionArgs) {
// Clear queue // Clear queue
await clearTeamQueue(teamId as string); await clearTeamQueue(teamId as string);
// TODO: Emit socket event for real-time update const io = getSocketIO();
// const io = getSocketIO(); io.to(`team-${teamId}`).emit("queue-updated", { queue: [] });
// io.to(`draft-${seasonId}`).emit("queue-updated", { teamId, queue: [] });
return Response.json({ success: true }); return Response.json({ success: true });
} }

View file

@ -3,6 +3,7 @@ import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { removeFromQueue, getTeamQueue } from "~/models/draft-queue"; import { removeFromQueue, getTeamQueue } from "~/models/draft-queue";
import { getSocketIO } from "../../../server/socket";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
@ -38,9 +39,8 @@ export async function action(args: ActionFunctionArgs) {
// Get updated queue // Get updated queue
const updatedQueue = await getTeamQueue(teamId as string); const updatedQueue = await getTeamQueue(teamId as string);
// TODO: Emit socket event for real-time update const io = getSocketIO();
// const io = getSocketIO(); io.to(`team-${teamId}`).emit("queue-updated", { queue: updatedQueue });
// io.to(`draft-${seasonId}`).emit("queue-updated", { teamId, queue: updatedQueue });
return Response.json({ success: true, queue: updatedQueue }); return Response.json({ success: true, queue: updatedQueue });
} }

View file

@ -3,6 +3,7 @@ import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { reorderQueueWithSeason, getTeamQueue } from "~/models/draft-queue"; import { reorderQueueWithSeason, getTeamQueue } from "~/models/draft-queue";
import { getSocketIO } from "../../../server/socket";
import type { ActionFunctionArgs } from "react-router"; import type { ActionFunctionArgs } from "react-router";
export async function action(args: ActionFunctionArgs) { export async function action(args: ActionFunctionArgs) {
@ -46,9 +47,8 @@ export async function action(args: ActionFunctionArgs) {
// Get updated queue // Get updated queue
const updatedQueue = await getTeamQueue(teamId as string); const updatedQueue = await getTeamQueue(teamId as string);
// TODO: Emit socket event for real-time update const io = getSocketIO();
// const io = getSocketIO(); io.to(`team-${teamId}`).emit("queue-updated", { queue: updatedQueue });
// io.to(`draft-${seasonId}`).emit("queue-updated", { teamId, queue: updatedQueue });
return Response.json({ success: true, queue: updatedQueue }); return Response.json({ success: true, queue: updatedQueue });
} }

View file

@ -433,6 +433,17 @@ export default function DraftRoom() {
// data (e.g. network failure) and we should NOT overwrite state that may // data (e.g. network failure) and we should NOT overwrite state that may
// have been freshly set by draft-state-sync. // have been freshly set by draft-state-sync.
const draftPicksAtRevalidationStartRef = useRef(draftPicks); const draftPicksAtRevalidationStartRef = useRef(draftPicks);
// Same pattern for userQueue: only sync from loader if the reference changed,
// meaning the fetch actually returned fresh data. Prevents overwriting
// socket-applied queue changes (queue-updated, participant-removed-from-queues)
// with stale loader data when a revalidation fetch fails.
const userQueueAtRevalidationStartRef = useRef(userQueue);
// Tracks how many queue mutations are in-flight from this window. While > 0,
// incoming queue-updated socket events are ignored so that the server echo
// doesn't clobber a more-recent optimistic update (e.g. rapid drag-reorders).
// The initiating window uses the HTTP response as the authoritative state;
// other windows use the socket event.
const pendingQueueMutationsRef = useRef(0);
useEffect(() => { useEffect(() => {
const prev = revalidatorStateRef.current; const prev = revalidatorStateRef.current;
@ -443,6 +454,7 @@ export default function DraftRoom() {
isRevalidatingRef.current = true; isRevalidatingRef.current = true;
pendingPicksDuringRevalidationRef.current = []; pendingPicksDuringRevalidationRef.current = [];
draftPicksAtRevalidationStartRef.current = draftPicks; draftPicksAtRevalidationStartRef.current = draftPicks;
userQueueAtRevalidationStartRef.current = userQueue;
} else if (prev === "loading" && revalidatorState === "idle") { } else if (prev === "loading" && revalidatorState === "idle") {
// Revalidation just completed — merge DB snapshot with buffered picks // Revalidation just completed — merge DB snapshot with buffered picks
isRevalidatingRef.current = false; isRevalidatingRef.current = false;
@ -467,22 +479,16 @@ export default function DraftRoom() {
setIsDraftComplete( setIsDraftComplete(
season.status === "active" || season.status === "completed" season.status === "active" || season.status === "completed"
); );
// Sync the queue — participants drafted while we were away will have // Sync the queue if the loader returned fresh data for it. Using reference
// been removed server-side, but we never received those // equality (same pattern as draftPicks above): drizzle always returns a new
// `participant-removed-from-queues` events. The loader re-fetches // array on a successful fetch, so a changed reference means the fetch
// userQueue fresh on every revalidation so we can trust it here. // succeeded and we can trust the value. A same reference means the fetch
// failed and we must NOT overwrite socket-applied changes (queue-updated,
// participant-removed-from-queues) with stale data.
// //
// Guard: only sync user-specific state if the loader still has auth. // Guard: skip if the loader ran unauthenticated (currentUserId null) to avoid
// If the revalidation ran with an expired JWT, currentUserId will be // overwriting with an empty userQueue returned for an expired JWT session.
// null and userQueue will be [] — don't overwrite the local queue. if (currentUserId && userQueue !== userQueueAtRevalidationStartRef.current) {
//
// Note: userTeam (which gates the queue tab) comes directly from
// useLoaderData() and is not synced through local state, so the queue
// tab will briefly disappear between a bad (unauthenticated) revalidation
// and the follow-up recovery revalidation triggered by the auth recovery
// effect above. This is acceptable — the tab reappears as soon as the
// second revalidation completes with a valid token.
if (currentUserId) {
setQueue(userQueue); setQueue(userQueue);
} }
// Sync timers — the server-side timer state may have changed while // Sync timers — the server-side timer state may have changed while
@ -824,6 +830,15 @@ export default function DraftRoom() {
setQueue((prev: any) => prev.filter((item: any) => !removed.has(item.participantId))); setQueue((prev: any) => prev.filter((item: any) => !removed.has(item.participantId)));
}; };
// Fired when another window/session changes the user's queue (add/remove/reorder/clear).
// Only received by this team's own sockets (emitted to team-${teamId} private room).
// Ignored while this window has in-flight mutations to prevent the server echo
// from clobbering a more-recent optimistic update.
const handleQueueUpdated = (data: { queue: any[] }) => {
if (pendingQueueMutationsRef.current > 0) return;
setQueue(data.queue);
};
const handlePickReplaced = (data: any) => { const handlePickReplaced = (data: any) => {
setPicks((prev: any) => setPicks((prev: any) =>
prev.map((p: any) => (p.pickNumber === data.pickNumber ? data.pick : p)) prev.map((p: any) => (p.pickNumber === data.pickNumber ? data.pick : p))
@ -863,6 +878,9 @@ export default function DraftRoom() {
return updated; return updated;
}); });
} }
if (data.queue) {
setQueue(data.queue);
}
} }
}; };
@ -877,6 +895,7 @@ export default function DraftRoom() {
on("connected-teams-list", handleConnectedTeamsList); on("connected-teams-list", handleConnectedTeamsList);
on("participant-removed-from-queues", handleParticipantRemovedFromQueues); on("participant-removed-from-queues", handleParticipantRemovedFromQueues);
on("queue-eligibility-pruned", handleQueueEligibilityPruned); on("queue-eligibility-pruned", handleQueueEligibilityPruned);
on("queue-updated", handleQueueUpdated);
on("pick-replaced", handlePickReplaced); on("pick-replaced", handlePickReplaced);
on("draft-rolled-back", handleDraftRolledBack); on("draft-rolled-back", handleDraftRolledBack);
on("draft-state-sync", handleDraftStateSync); on("draft-state-sync", handleDraftStateSync);
@ -893,6 +912,7 @@ export default function DraftRoom() {
off("connected-teams-list", handleConnectedTeamsList); off("connected-teams-list", handleConnectedTeamsList);
off("participant-removed-from-queues", handleParticipantRemovedFromQueues); off("participant-removed-from-queues", handleParticipantRemovedFromQueues);
off("queue-eligibility-pruned", handleQueueEligibilityPruned); off("queue-eligibility-pruned", handleQueueEligibilityPruned);
off("queue-updated", handleQueueUpdated);
off("pick-replaced", handlePickReplaced); off("pick-replaced", handlePickReplaced);
off("draft-rolled-back", handleDraftRolledBack); off("draft-rolled-back", handleDraftRolledBack);
off("draft-state-sync", handleDraftStateSync); off("draft-state-sync", handleDraftStateSync);
@ -943,6 +963,7 @@ export default function DraftRoom() {
formData.append("teamId", userTeam.id); formData.append("teamId", userTeam.id);
formData.append("participantId", participantId); formData.append("participantId", participantId);
pendingQueueMutationsRef.current++;
try { try {
const response = await authFetch("/api/queue/add", { const response = await authFetch("/api/queue/add", {
method: "POST", method: "POST",
@ -968,6 +989,8 @@ export default function DraftRoom() {
// Revert the optimistic update on network error // Revert the optimistic update on network error
setQueue((prev) => prev.filter((item) => item.id !== tempQueueItem.id)); setQueue((prev) => prev.filter((item) => item.id !== tempQueueItem.id));
toast.error("Network error - failed to add to queue"); toast.error("Network error - failed to add to queue");
} finally {
pendingQueueMutationsRef.current--;
} }
// queue is read directly (not via functional updater) for the duplicate check and // queue is read directly (not via functional updater) for the duplicate check and
// optimistic position, so it must be a dep. This is fine: AvailableParticipantsSection // optimistic position, so it must be a dep. This is fine: AvailableParticipantsSection
@ -988,6 +1011,7 @@ export default function DraftRoom() {
formData.append("queueId", queueId); formData.append("queueId", queueId);
formData.append("teamId", userTeam.id); formData.append("teamId", userTeam.id);
pendingQueueMutationsRef.current++;
try { try {
const response = await authFetch("/api/queue/remove", { const response = await authFetch("/api/queue/remove", {
method: "POST", method: "POST",
@ -1006,6 +1030,8 @@ export default function DraftRoom() {
} catch { } catch {
setQueue(previousQueue); setQueue(previousQueue);
toast.error("Network error - failed to remove from queue"); toast.error("Network error - failed to remove from queue");
} finally {
pendingQueueMutationsRef.current--;
} }
}, [userTeam, authFetch]); }, [userTeam, authFetch]);
@ -1026,6 +1052,7 @@ export default function DraftRoom() {
formData.append("seasonId", season.id); formData.append("seasonId", season.id);
formData.append("participantIds", JSON.stringify(participantIds)); formData.append("participantIds", JSON.stringify(participantIds));
pendingQueueMutationsRef.current++;
try { try {
const response = await authFetch("/api/queue/reorder", { const response = await authFetch("/api/queue/reorder", {
method: "POST", method: "POST",
@ -1044,6 +1071,8 @@ export default function DraftRoom() {
} catch { } catch {
setQueue(previousQueue); setQueue(previousQueue);
toast.error("Network error - failed to reorder queue"); toast.error("Network error - failed to reorder queue");
} finally {
pendingQueueMutationsRef.current--;
} }
}, [userTeam, season.id, authFetch]); }, [userTeam, season.id, authFetch]);

View file

@ -47,6 +47,7 @@ interface ServerToClientEvents {
"draft-paused": (data: { seasonId: string; paused: boolean }) => void; "draft-paused": (data: { seasonId: string; paused: boolean }) => void;
"draft-resumed": (data: { seasonId: string; paused: boolean }) => void; "draft-resumed": (data: { seasonId: string; paused: boolean }) => void;
"participant-removed-from-queues": (data: { participantId: string }) => void; "participant-removed-from-queues": (data: { participantId: string }) => void;
"queue-updated": (data: { queue: Array<{ id: string; teamId: string; seasonId: string; participantId: string; queuePosition: number }> }) => void;
"draft-state-sync": (data: { "draft-state-sync": (data: {
currentPickNumber: number; currentPickNumber: number;
isPaused: boolean; isPaused: boolean;
@ -65,6 +66,13 @@ interface ServerToClientEvents {
teamId: string; teamId: string;
timeRemaining: number; timeRemaining: number;
}>; }>;
queue?: Array<{
id: string;
teamId: string;
seasonId: string;
participantId: string;
queuePosition: number;
}>;
}) => void; }) => void;
} }
@ -175,7 +183,7 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
// additional, more reliable path since the socket is already connected. // additional, more reliable path since the socket is already connected.
try { try {
const db = getSocketDb(); const db = getSocketDb();
const [seasonData, picks, timerRows] = await Promise.all([ const [seasonData, picks, timerRows, queueItems] = await Promise.all([
db.query.seasons.findFirst({ db.query.seasons.findFirst({
where: eq(schema.seasons.id, seasonId), where: eq(schema.seasons.id, seasonId),
}), }),
@ -209,6 +217,15 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
db.query.draftTimers.findMany({ db.query.draftTimers.findMany({
where: eq(schema.draftTimers.seasonId, seasonId), where: eq(schema.draftTimers.seasonId, seasonId),
}), }),
teamId
? db.query.draftQueue.findMany({
where: and(
eq(schema.draftQueue.teamId, teamId),
eq(schema.draftQueue.seasonId, seasonId)
),
orderBy: asc(schema.draftQueue.queuePosition),
})
: Promise.resolve([]),
]); ]);
if (seasonData) { if (seasonData) {
@ -221,6 +238,7 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
teamId: t.teamId, teamId: t.teamId,
timeRemaining: t.timeRemaining, timeRemaining: t.timeRemaining,
})), })),
queue: teamId ? queueItems : undefined,
}); });
} }
} catch (err) { } catch (err) {