feat: implement real-time queue updates via socket events in queue actions (#63)
This commit is contained in:
parent
435acdeef0
commit
472ceca92d
6 changed files with 76 additions and 28 deletions
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue