Add pre-draft queue builder so users can rank players before draft order is set

Creates a new /leagues/:leagueId/draft-queue/:seasonId page that lets team
owners browse participants by VORP and build their autopick queue during the
pre_draft phase. The queue uses the existing draftQueue table so it carries
seamlessly into the live draft room.

Adds a "Build Your Queue" button to DraftInfoCard that shows only when draft
order has not been set yet (replaced by "Enter Draft Room" once it is).

https://claude.ai/code/session_01Gu2DkTWL3nv74EMuGPpxhG
This commit is contained in:
Claude 2026-05-15 23:37:10 +00:00
parent 55828e9f42
commit 3d9f767a8c
No known key found for this signature in database
4 changed files with 241 additions and 2 deletions

View file

@ -21,6 +21,8 @@ export interface DraftInfoCardProps {
draftDateTime?: string | Date | null;
draftBoardHref: string;
draftRoomHref: string;
/** Link to the pre-draft queue builder; shown only when draft order is not yet set and user has a team */
queueBuilderHref?: string;
/** 1-based draft position for the current user; omit or undefined to hide the panel */
userDraftPosition?: number;
/** IANA timezone used to display the draft date and time. */
@ -57,6 +59,7 @@ function DraftInfoCardVariant({
draftDateTime,
draftTimezone,
draftRoomHref,
queueBuilderHref,
userDraftPosition,
overnightPauseMode,
overnightPauseStart,
@ -112,11 +115,15 @@ function DraftInfoCardVariant({
<GradientIcon icon={LayoutGrid} className="h-5 w-5 shrink-0" />
<h2 className="text-xl font-bold leading-none tracking-tight">Draft Info</h2>
</div>
{isDraftOrderSet && (
{isDraftOrderSet ? (
<Button asChild>
<Link to={draftRoomHref}>Enter Draft Room</Link>
</Button>
)}
) : queueBuilderHref ? (
<Button asChild variant="outline">
<Link to={queueBuilderHref}>Build Your Queue</Link>
</Button>
) : null}
</div>
</CardHeader>

View file

@ -20,6 +20,10 @@ export default [
"leagues/:leagueId/draft/:seasonId",
"routes/leagues/$leagueId.draft.$seasonId.tsx"
),
route(
"leagues/:leagueId/draft-queue/:seasonId",
"routes/leagues/$leagueId.draft-queue.$seasonId.tsx"
),
route(
"leagues/:leagueId/draft-board/:seasonId",
"routes/leagues/$leagueId.draft-board.$seasonId.tsx"

View file

@ -0,0 +1,227 @@
import { redirect, useFetcher, useLoaderData, useRevalidator, Link } from "react-router";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ArrowLeft, ListOrdered } from "lucide-react";
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 type { Route } from "./+types/$leagueId.draft-queue.$seasonId";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `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 build a 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,
leagueId,
userTeam,
availableParticipants,
userQueue,
};
}
type QueueItem = { id: string; participantId: string };
export default function PreDraftQueue() {
const { season, leagueId, userTeam, availableParticipants, userQueue } =
useLoaderData<typeof loader>();
const { revalidate } = useRevalidator();
const [localQueue, setLocalQueue] = useState<QueueItem[]>(userQueue);
// Sync local state when server data refreshes
useEffect(() => {
setLocalQueue(userQueue);
}, [userQueue]);
const addFetcher = useFetcher<{ success: boolean }>();
const removeFetcher = useFetcher();
const reorderFetcher = useFetcher();
// After add completes, revalidate to replace the optimistic temp ID with the real one
useEffect(() => {
if (addFetcher.state === "idle" && addFetcher.data?.success) {
revalidate();
}
}, [addFetcher.state, addFetcher.data, revalidate]);
const queuedParticipantIds = useMemo(
() => new Set(localQueue.map((q) => q.participantId)),
[localQueue]
);
const handleAdd = useCallback(
(participantId: string) => {
if (queuedParticipantIds.has(participantId)) return;
// Optimistic update with a temporary ID
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);
addFetcher.submit(formData, { method: "POST", action: "/api/queue/add" });
},
[queuedParticipantIds, season.id, userTeam.id, addFetcher]
);
const handleRemove = useCallback(
(queueId: string) => {
setLocalQueue((prev) => prev.filter((item) => item.id !== queueId));
const formData = new FormData();
formData.append("queueId", queueId);
formData.append("teamId", userTeam.id);
removeFetcher.submit(formData, { method: "POST", action: "/api/queue/remove" });
},
[userTeam.id, removeFetcher]
);
const handleReorder = useCallback(
(participantIds: string[]) => {
setLocalQueue((prev) => {
const byParticipantId = new Map(prev.map((item) => [item.participantId, item]));
return participantIds
.map((pid) => byParticipantId.get(pid))
.filter((item): item is (typeof prev)[number] => item !== undefined);
});
const formData = new FormData();
formData.append("teamId", userTeam.id);
formData.append("seasonId", season.id);
formData.append("participantIds", JSON.stringify(participantIds));
reorderFetcher.submit(formData, { method: "POST", action: "/api/queue/reorder" });
},
[userTeam.id, season.id, reorderFetcher]
);
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">Pre-Draft Queue</h1>
</div>
<p className="text-sm text-muted-foreground">
Rank the participants you want. Your queue will carry into the live draft and determine
your autopick order.
</p>
</div>
{/* Two-panel layout */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Participant list */}
<div className="lg:col-span-2">
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold">All Participants</h2>
<span className="text-xs text-muted-foreground">Sorted by VORP</span>
</div>
<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>
{p.vorpValue != null && (
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
{Number(p.vorpValue).toFixed(1)}
</span>
)}
<Button
size="sm"
variant={inQueue ? "secondary" : "outline"}
disabled={inQueue}
onClick={() => handleAdd(p.id)}
className="h-7 text-xs shrink-0"
>
{inQueue ? "Queued" : "+ 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>
</div>
{/* Queue panel */}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold">Your Queue</h2>
<Badge variant="secondary">{localQueue.length}</Badge>
</div>
<div className="rounded-lg border bg-card">
<QueueSection
queue={localQueue}
availableParticipants={availableParticipants}
canPick={false}
onRemoveFromQueue={handleRemove}
onReorder={handleReorder}
/>
</div>
{localQueue.length > 0 && (
<p className="text-xs text-muted-foreground mt-2 text-center">
Drag to reorder · Autopick follows this order
</p>
)}
</div>
</div>
</div>
);
}

View file

@ -210,6 +210,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
draftTimezone={currentUserTimezone ?? season.overnightPauseTimezone}
draftBoardHref={`/leagues/${league.id}/draft-board/${season.id}`}
draftRoomHref={`/leagues/${league.id}/draft/${season.id}`}
queueBuilderHref={myTeam && season.status === "pre_draft" ? `/leagues/${league.id}/draft-queue/${season.id}` : undefined}
userDraftPosition={userDraftPosition}
overnightPauseMode={season.overnightPauseMode}
overnightPauseStart={season.overnightPauseStart}