brackt/app/routes/leagues/$leagueId.draft-queue.$seasonId.tsx
Chris Parsons faecfd8ecc
Rename queue page to Set Pre-Draft Queue, remove VORP, fix re-add race condition (#436)
* Rename queue page to Set Pre-Draft Queue, remove VORP, fix re-add race condition

- Rename page title, h1, button, error message, and tests from
  "Pre-Draft Rankings" to "Set Pre-Draft Queue"
- Remove VORP values and "Sorted by VORP" labels from the all-players list
- Fix bug where removing a player then immediately re-adding would fail
  with "already in queue": track in-flight removes in a ref and guard
  handleAdd against firing while a remove is still in-flight

https://claude.ai/code/session_01TGAV9A2c72PNkL1ffY7Xeq

* Address code review findings on Set Pre-Draft Queue page

- Rename component function from PreDraftRankings to SetPreDraftQueue
- Guard handleRemove against temp IDs — items added optimistically but
  not yet written to the DB have no server-side record to delete; skip
  the API call and just drop them from local state
- Surface a toast when handleAdd is blocked by a pending remove instead
  of silently no-oping
- Group both refs before their shared useEffect for readability
- Drop justify-between from the All Players desktop heading (sole child)
- Update test describe blocks to "Set Pre-Draft Queue Access"

https://claude.ai/code/session_01TGAV9A2c72PNkL1ffY7Xeq

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-15 18:47:36 -07:00

335 lines
12 KiB
TypeScript

import { redirect, useLoaderData, useRevalidator, Link, useParams } from "react-router";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ArrowLeft, ListOrdered, Users } from "lucide-react";
import { toast } from "sonner";
import { auth } from "~/lib/auth.server";
import { findSeasonWithTeamsAndLeague } from "~/models/season";
import { getDraftParticipants } from "~/models/season-participant";
import { getTeamQueue } from "~/models/draft-queue";
import { QueueSection } from "~/components/draft/QueueSection";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import type { Route } from "./+types/$leagueId.draft-queue.$seasonId";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Set Pre-Draft Queue — ${data?.season?.league?.name ?? "League"} - Brackt` }];
}
export async function loader(args: Route.LoaderArgs) {
const { params } = args;
const { seasonId, leagueId } = params;
const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
if (!seasonId) {
throw new Response("Season ID is required", { status: 400 });
}
const season = await findSeasonWithTeamsAndLeague(seasonId);
if (!season) {
throw new Response("Season not found", { status: 404 });
}
// Redirect if draft has started — send them to the appropriate page
if (season.status === "draft") {
return redirect(`/leagues/${leagueId}/draft/${seasonId}`);
}
if (season.status === "active" || season.status === "completed") {
return redirect(`/leagues/${leagueId}/draft-board/${seasonId}`);
}
if (!userId) {
throw new Response("You must be logged in to set pre-draft queue", { status: 401 });
}
const userTeam = season.teams.find((t) => t.ownerId === userId);
if (!userTeam) {
throw new Response("You do not have a team in this season", { status: 403 });
}
const [availableParticipants, userQueue] = await Promise.all([
getDraftParticipants(seasonId),
getTeamQueue(userTeam.id),
]);
return {
season,
userTeam,
availableParticipants,
userQueue,
};
}
type QueueItem = { id: string; participantId: string };
export default function SetPreDraftQueue() {
const { season, userTeam, availableParticipants, userQueue } =
useLoaderData<typeof loader>();
const { leagueId } = useParams<{ leagueId: string }>();
const { revalidate } = useRevalidator();
const [localQueue, setLocalQueue] = useState<QueueItem[]>(userQueue);
// Sync local state when server data refreshes
useEffect(() => {
setLocalQueue(userQueue);
}, [userQueue]);
// Always-current ref so reorder callback stays stable (no localQueue dependency)
const queueRef = useRef(localQueue);
// Track in-flight removes to prevent add racing ahead of a pending remove
const pendingRemovesRef = useRef(new Set<string>());
useEffect(() => {
queueRef.current = localQueue;
}, [localQueue]);
const queuedParticipantIds = useMemo(
() => new Set(localQueue.map((q) => q.participantId)),
[localQueue]
);
const handleAdd = useCallback(
async (participantId: string) => {
if (queuedParticipantIds.has(participantId)) return;
if (pendingRemovesRef.current.has(participantId)) {
toast("Player is being removed — try again in a moment");
return;
}
const tempId = `temp-${Date.now()}-${participantId}`;
setLocalQueue((prev) => [...prev, { id: tempId, participantId }]);
const formData = new FormData();
formData.append("seasonId", season.id);
formData.append("teamId", userTeam.id);
formData.append("participantId", participantId);
try {
const res = await fetch("/api/queue/add", { method: "POST", body: formData });
const data = await res.json();
if (data.error) {
setLocalQueue((prev) => prev.filter((item) => item.id !== tempId));
toast.error(data.error || "Failed to add to rankings");
return;
}
revalidate(); // swap temp ID for the real server-assigned ID
} catch {
setLocalQueue((prev) => prev.filter((item) => item.id !== tempId));
toast.error("Network error — failed to add to rankings");
}
},
[queuedParticipantIds, season.id, userTeam.id, revalidate]
);
const handleRemove = useCallback(
async (queueId: string) => {
const item = queueRef.current.find((q) => q.id === queueId);
const participantId = item?.participantId;
setLocalQueue((prev) => prev.filter((q) => q.id !== queueId));
// Temp IDs haven't been written to the DB yet — nothing to delete server-side.
if (queueId.startsWith("temp-")) return;
if (participantId) pendingRemovesRef.current.add(participantId);
const formData = new FormData();
formData.append("queueId", queueId);
formData.append("teamId", userTeam.id);
try {
const res = await fetch("/api/queue/remove", { method: "POST", body: formData });
const data = await res.json();
if (data.error) {
toast.error(data.error || "Failed to remove from rankings");
revalidate();
}
} catch {
toast.error("Network error — failed to remove from rankings");
revalidate();
} finally {
if (participantId) pendingRemovesRef.current.delete(participantId);
}
},
[userTeam.id, revalidate]
);
// Remove by participantId — used from the All Players list
const handleRemoveByParticipantId = useCallback(
(participantId: string) => {
const item = queueRef.current.find((q) => q.participantId === participantId);
if (item) handleRemove(item.id);
},
[handleRemove]
);
const handleReorder = useCallback(
async (participantIds: string[]) => {
const previousQueue = queueRef.current;
setLocalQueue((prev) => {
const byParticipantId = new Map(prev.map((item) => [item.participantId, item]));
return participantIds
.map((pid) => byParticipantId.get(pid))
.filter((item): item is QueueItem => item !== undefined);
});
const formData = new FormData();
formData.append("teamId", userTeam.id);
formData.append("seasonId", season.id);
formData.append("participantIds", JSON.stringify(participantIds));
try {
const res = await fetch("/api/queue/reorder", { method: "POST", body: formData });
const data = await res.json();
if (data.error) {
setLocalQueue(previousQueue);
toast.error(data.error || "Failed to reorder rankings");
}
} catch {
setLocalQueue(previousQueue);
toast.error("Network error — failed to reorder rankings");
}
},
[userTeam.id, season.id]
);
// ── Shared panel content ────────────────────────────────────────────────────
const allPlayersPanel = useMemo(
() => (
<div className="space-y-1">
{availableParticipants.map((p) => {
const inQueue = queuedParticipantIds.has(p.id);
return (
<div
key={p.id}
className="flex items-center gap-3 rounded-lg bg-muted/50 px-3 py-2"
>
<div className="min-w-0 flex-1">
<p className="font-medium text-sm truncate">{p.name}</p>
<p className="text-xs text-muted-foreground">{p.sport.name}</p>
</div>
{inQueue ? (
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveByParticipantId(p.id)}
className="h-7 text-xs shrink-0 text-destructive hover:text-destructive hover:bg-destructive/10"
>
Remove
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => handleAdd(p.id)}
className="h-7 text-xs shrink-0"
>
+ Add
</Button>
)}
</div>
);
})}
{availableParticipants.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-12">
No participants have been added to this season yet.
</p>
)}
</div>
),
[availableParticipants, queuedParticipantIds, handleAdd, handleRemoveByParticipantId]
);
const rankingsPanel = useMemo(
() => (
<>
<div className="rounded-lg border bg-card">
<QueueSection
queue={localQueue}
availableParticipants={availableParticipants}
canPick={false}
onRemoveFromQueue={handleRemove}
onReorder={handleReorder}
emptyMessage="Add players from All Players to set your ranking order."
/>
</div>
{localQueue.length > 0 && (
<p className="text-xs text-muted-foreground mt-2 text-center">
Drag to reorder · Autopick follows this order
</p>
)}
</>
),
[localQueue, availableParticipants, handleRemove, handleReorder]
);
// ── Render ──────────────────────────────────────────────────────────────────
return (
<div className="container mx-auto py-8 px-4">
{/* Header */}
<div className="mb-6">
<Link
to={`/leagues/${leagueId}`}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground mb-3"
>
<ArrowLeft className="h-4 w-4" />
Back to {season.league.name}
</Link>
<div className="flex items-center gap-2 mb-1">
<ListOrdered className="h-6 w-6" />
<h1 className="text-2xl font-bold">Set Pre-Draft Queue</h1>
</div>
<p className="text-sm text-muted-foreground">
Rank the players you want. Your rankings carry into the live draft as your autopick
priority order.
</p>
</div>
{/* Mobile: tabs */}
<div className="lg:hidden">
<Tabs defaultValue="players">
<TabsList className="grid w-full grid-cols-2 mb-4">
<TabsTrigger value="players" className="flex items-center gap-1.5 text-xs">
<Users className="h-4 w-4" />
All Players
</TabsTrigger>
<TabsTrigger value="rankings" className="flex items-center gap-1.5 text-xs">
<ListOrdered className="h-4 w-4" />
My Rankings
{localQueue.length > 0 && (
<Badge variant="secondary" className="ml-1 h-4 px-1 text-[10px]">
{localQueue.length}
</Badge>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="players">
{allPlayersPanel}
</TabsContent>
<TabsContent value="rankings">{rankingsPanel}</TabsContent>
</Tabs>
</div>
{/* Desktop: side-by-side */}
<div className="hidden lg:grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<div className="mb-3">
<h2 className="font-semibold">All Players</h2>
</div>
{allPlayersPanel}
</div>
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold">My Rankings</h2>
<Badge variant="secondary">{localQueue.length}</Badge>
</div>
{rankingsPanel}
</div>
</div>
</div>
);
}