From 472ceca92d5e7142e8ab84d3a5c1a04860ccd3b9 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:39:54 -0800 Subject: [PATCH] feat: implement real-time queue updates via socket events in queue actions (#63) --- app/routes/api/queue.add.ts | 7 ++- app/routes/api/queue.clear.ts | 6 +- app/routes/api/queue.remove.ts | 6 +- app/routes/api/queue.reorder.ts | 6 +- .../leagues/$leagueId.draft.$seasonId.tsx | 59 ++++++++++++++----- server/socket.ts | 20 ++++++- 6 files changed, 76 insertions(+), 28 deletions(-) diff --git a/app/routes/api/queue.add.ts b/app/routes/api/queue.add.ts index beb70b4..f18f586 100644 --- a/app/routes/api/queue.add.ts +++ b/app/routes/api/queue.add.ts @@ -3,6 +3,7 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq } from "drizzle-orm"; import { addToQueue, getTeamQueue, isParticipantInQueue } from "~/models/draft-queue"; +import { getSocketIO } from "../../../server/socket"; import type { ActionFunctionArgs } from "react-router"; export async function action(args: ActionFunctionArgs) { @@ -51,9 +52,9 @@ export async function action(args: ActionFunctionArgs) { queuePosition, }); - // TODO: Emit socket event for real-time update - // const io = getSocketIO(); - // io.to(`draft-${seasonId}`).emit("queue-updated", { teamId, queue: [...currentQueue, queueItem] }); + const updatedQueue = await getTeamQueue(teamId as string); + const io = getSocketIO(); + io.to(`team-${teamId}`).emit("queue-updated", { queue: updatedQueue }); return Response.json({ success: true, queueItem }); } diff --git a/app/routes/api/queue.clear.ts b/app/routes/api/queue.clear.ts index b8bc6a5..23c3c5d 100644 --- a/app/routes/api/queue.clear.ts +++ b/app/routes/api/queue.clear.ts @@ -3,6 +3,7 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq } from "drizzle-orm"; import { clearTeamQueue } from "~/models/draft-queue"; +import { getSocketIO } from "../../../server/socket"; import type { ActionFunctionArgs } from "react-router"; export async function action(args: ActionFunctionArgs) { @@ -34,9 +35,8 @@ export async function action(args: ActionFunctionArgs) { // Clear queue await clearTeamQueue(teamId as string); - // TODO: Emit socket event for real-time update - // const io = getSocketIO(); - // io.to(`draft-${seasonId}`).emit("queue-updated", { teamId, queue: [] }); + const io = getSocketIO(); + io.to(`team-${teamId}`).emit("queue-updated", { queue: [] }); return Response.json({ success: true }); } diff --git a/app/routes/api/queue.remove.ts b/app/routes/api/queue.remove.ts index bd23b2d..2905b4b 100644 --- a/app/routes/api/queue.remove.ts +++ b/app/routes/api/queue.remove.ts @@ -3,6 +3,7 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq } from "drizzle-orm"; import { removeFromQueue, getTeamQueue } from "~/models/draft-queue"; +import { getSocketIO } from "../../../server/socket"; import type { ActionFunctionArgs } from "react-router"; export async function action(args: ActionFunctionArgs) { @@ -38,9 +39,8 @@ export async function action(args: ActionFunctionArgs) { // Get updated queue const updatedQueue = await getTeamQueue(teamId as string); - // TODO: Emit socket event for real-time update - // const io = getSocketIO(); - // io.to(`draft-${seasonId}`).emit("queue-updated", { teamId, queue: updatedQueue }); + const io = getSocketIO(); + io.to(`team-${teamId}`).emit("queue-updated", { queue: updatedQueue }); return Response.json({ success: true, queue: updatedQueue }); } diff --git a/app/routes/api/queue.reorder.ts b/app/routes/api/queue.reorder.ts index bb09981..7426697 100644 --- a/app/routes/api/queue.reorder.ts +++ b/app/routes/api/queue.reorder.ts @@ -3,6 +3,7 @@ import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq } from "drizzle-orm"; import { reorderQueueWithSeason, getTeamQueue } from "~/models/draft-queue"; +import { getSocketIO } from "../../../server/socket"; import type { ActionFunctionArgs } from "react-router"; export async function action(args: ActionFunctionArgs) { @@ -46,9 +47,8 @@ export async function action(args: ActionFunctionArgs) { // Get updated queue const updatedQueue = await getTeamQueue(teamId as string); - // TODO: Emit socket event for real-time update - // const io = getSocketIO(); - // io.to(`draft-${seasonId}`).emit("queue-updated", { teamId, queue: updatedQueue }); + const io = getSocketIO(); + io.to(`team-${teamId}`).emit("queue-updated", { queue: updatedQueue }); return Response.json({ success: true, queue: updatedQueue }); } diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index 46003f3..ef42f98 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -433,6 +433,17 @@ export default function DraftRoom() { // data (e.g. network failure) and we should NOT overwrite state that may // have been freshly set by draft-state-sync. 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(() => { const prev = revalidatorStateRef.current; @@ -443,6 +454,7 @@ export default function DraftRoom() { isRevalidatingRef.current = true; pendingPicksDuringRevalidationRef.current = []; draftPicksAtRevalidationStartRef.current = draftPicks; + userQueueAtRevalidationStartRef.current = userQueue; } else if (prev === "loading" && revalidatorState === "idle") { // Revalidation just completed — merge DB snapshot with buffered picks isRevalidatingRef.current = false; @@ -467,22 +479,16 @@ export default function DraftRoom() { setIsDraftComplete( season.status === "active" || season.status === "completed" ); - // Sync the queue — participants drafted while we were away will have - // been removed server-side, but we never received those - // `participant-removed-from-queues` events. The loader re-fetches - // userQueue fresh on every revalidation so we can trust it here. + // Sync the queue if the loader returned fresh data for it. Using reference + // equality (same pattern as draftPicks above): drizzle always returns a new + // array on a successful fetch, so a changed reference means the fetch + // 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. - // If the revalidation ran with an expired JWT, currentUserId will be - // null and userQueue will be [] — don't overwrite the local queue. - // - // 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) { + // Guard: skip if the loader ran unauthenticated (currentUserId null) to avoid + // overwriting with an empty userQueue returned for an expired JWT session. + if (currentUserId && userQueue !== userQueueAtRevalidationStartRef.current) { setQueue(userQueue); } // 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))); }; + // 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) => { setPicks((prev: any) => prev.map((p: any) => (p.pickNumber === data.pickNumber ? data.pick : p)) @@ -863,6 +878,9 @@ export default function DraftRoom() { return updated; }); } + if (data.queue) { + setQueue(data.queue); + } } }; @@ -877,6 +895,7 @@ export default function DraftRoom() { on("connected-teams-list", handleConnectedTeamsList); on("participant-removed-from-queues", handleParticipantRemovedFromQueues); on("queue-eligibility-pruned", handleQueueEligibilityPruned); + on("queue-updated", handleQueueUpdated); on("pick-replaced", handlePickReplaced); on("draft-rolled-back", handleDraftRolledBack); on("draft-state-sync", handleDraftStateSync); @@ -893,6 +912,7 @@ export default function DraftRoom() { off("connected-teams-list", handleConnectedTeamsList); off("participant-removed-from-queues", handleParticipantRemovedFromQueues); off("queue-eligibility-pruned", handleQueueEligibilityPruned); + off("queue-updated", handleQueueUpdated); off("pick-replaced", handlePickReplaced); off("draft-rolled-back", handleDraftRolledBack); off("draft-state-sync", handleDraftStateSync); @@ -943,6 +963,7 @@ export default function DraftRoom() { formData.append("teamId", userTeam.id); formData.append("participantId", participantId); + pendingQueueMutationsRef.current++; try { const response = await authFetch("/api/queue/add", { method: "POST", @@ -968,6 +989,8 @@ export default function DraftRoom() { // Revert the optimistic update on network error setQueue((prev) => prev.filter((item) => item.id !== tempQueueItem.id)); 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 // 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("teamId", userTeam.id); + pendingQueueMutationsRef.current++; try { const response = await authFetch("/api/queue/remove", { method: "POST", @@ -1006,6 +1030,8 @@ export default function DraftRoom() { } catch { setQueue(previousQueue); toast.error("Network error - failed to remove from queue"); + } finally { + pendingQueueMutationsRef.current--; } }, [userTeam, authFetch]); @@ -1026,6 +1052,7 @@ export default function DraftRoom() { formData.append("seasonId", season.id); formData.append("participantIds", JSON.stringify(participantIds)); + pendingQueueMutationsRef.current++; try { const response = await authFetch("/api/queue/reorder", { method: "POST", @@ -1044,6 +1071,8 @@ export default function DraftRoom() { } catch { setQueue(previousQueue); toast.error("Network error - failed to reorder queue"); + } finally { + pendingQueueMutationsRef.current--; } }, [userTeam, season.id, authFetch]); diff --git a/server/socket.ts b/server/socket.ts index 7701ba8..020bba9 100644 --- a/server/socket.ts +++ b/server/socket.ts @@ -47,6 +47,7 @@ interface ServerToClientEvents { "draft-paused": (data: { seasonId: string; paused: boolean }) => void; "draft-resumed": (data: { seasonId: string; paused: boolean }) => 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: { currentPickNumber: number; isPaused: boolean; @@ -65,6 +66,13 @@ interface ServerToClientEvents { teamId: string; timeRemaining: number; }>; + queue?: Array<{ + id: string; + teamId: string; + seasonId: string; + participantId: string; + queuePosition: number; + }>; }) => void; } @@ -175,7 +183,7 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer { // additional, more reliable path since the socket is already connected. try { const db = getSocketDb(); - const [seasonData, picks, timerRows] = await Promise.all([ + const [seasonData, picks, timerRows, queueItems] = await Promise.all([ db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId), }), @@ -209,6 +217,15 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer { db.query.draftTimers.findMany({ 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) { @@ -221,6 +238,7 @@ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer { teamId: t.teamId, timeRemaining: t.timeRemaining, })), + queue: teamId ? queueItems : undefined, }); } } catch (err) {