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 * 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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue