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 { 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 });
}

View file

@ -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 });
}

View file

@ -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 });
}

View file

@ -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 });
}

View file

@ -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]);

View file

@ -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) {