diff --git a/app/components/draft/QueueSection.tsx b/app/components/draft/QueueSection.tsx
index 8fab258..314da4b 100644
--- a/app/components/draft/QueueSection.tsx
+++ b/app/components/draft/QueueSection.tsx
@@ -34,6 +34,7 @@ interface QueueSectionProps {
onRemoveFromQueue: (queueId: string) => void;
onReorder: (participantIds: string[]) => void;
onMakePick?: (participantId: string) => void;
+ emptyMessage?: string;
}
// Sortable queue item component
@@ -125,6 +126,7 @@ export const QueueSection = memo(function QueueSection({
onRemoveFromQueue,
onReorder,
onMakePick,
+ emptyMessage = "Click participants in Available to add to your queue",
}: QueueSectionProps) {
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -160,7 +162,7 @@ export const QueueSection = memo(function QueueSection({
{/* Queue List */}
{queue.length === 0 ? (
- Click participants in Available to add to your queue
+ {emptyMessage}
) : (
Draft Info
- {isDraftOrderSet && (
+ {isDraftOrderSet ? (
- )}
+ ) : queueBuilderHref ? (
+
+ ) : null}
diff --git a/app/routes.ts b/app/routes.ts
index 9577079..2d055f1 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -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"
diff --git a/app/routes/leagues/$leagueId.draft-queue.$seasonId.tsx b/app/routes/leagues/$leagueId.draft-queue.$seasonId.tsx
new file mode 100644
index 0000000..2899cc6
--- /dev/null
+++ b/app/routes/leagues/$leagueId.draft-queue.$seasonId.tsx
@@ -0,0 +1,327 @@
+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: `Pre-Draft Rankings — ${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 rankings", { 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 PreDraftRankings() {
+ const { season, userTeam, availableParticipants, userQueue } =
+ useLoaderData();
+ const { leagueId } = useParams<{ leagueId: string }>();
+ const { revalidate } = useRevalidator();
+
+ const [localQueue, setLocalQueue] = useState(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);
+ 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;
+ 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) => {
+ setLocalQueue((prev) => prev.filter((item) => item.id !== queueId));
+
+ 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();
+ }
+ },
+ [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(
+ () => (
+
+ {availableParticipants.map((p) => {
+ const inQueue = queuedParticipantIds.has(p.id);
+ return (
+
+
+
{p.name}
+
{p.sport.name}
+
+ {p.vorpValue !== null && p.vorpValue !== undefined && (
+
+ {Number(p.vorpValue).toFixed(1)}
+
+ )}
+ {inQueue ? (
+
+ ) : (
+
+ )}
+
+ );
+ })}
+ {availableParticipants.length === 0 && (
+
+ No participants have been added to this season yet.
+
+ )}
+
+ ),
+ [availableParticipants, queuedParticipantIds, handleAdd, handleRemoveByParticipantId]
+ );
+
+ const rankingsPanel = useMemo(
+ () => (
+ <>
+
+
+
+ {localQueue.length > 0 && (
+
+ Drag to reorder · Autopick follows this order
+
+ )}
+ >
+ ),
+ [localQueue, availableParticipants, handleRemove, handleReorder]
+ );
+
+ // ── Render ──────────────────────────────────────────────────────────────────
+
+ return (
+
+ {/* Header */}
+
+
+
+ Back to {season.league.name}
+
+
+
+
Pre-Draft Rankings
+
+
+ Rank the players you want. Your rankings carry into the live draft as your autopick
+ priority order.
+
+
+
+ {/* Mobile: tabs */}
+
+
+
+
+
+ All Players
+
+
+
+ My Rankings
+ {localQueue.length > 0 && (
+
+ {localQueue.length}
+
+ )}
+
+
+
+
+ Sorted by VORP
+
+ {allPlayersPanel}
+
+ {rankingsPanel}
+
+
+
+ {/* Desktop: side-by-side */}
+
+
+
+
All Players
+ Sorted by VORP
+
+ {allPlayersPanel}
+
+
+
+
My Rankings
+ {localQueue.length}
+
+ {rankingsPanel}
+
+
+
+ );
+}
diff --git a/app/routes/leagues/$leagueId.tsx b/app/routes/leagues/$leagueId.tsx
index 86cd700..d527196 100644
--- a/app/routes/leagues/$leagueId.tsx
+++ b/app/routes/leagues/$leagueId.tsx
@@ -210,6 +210,11 @@ 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}
diff --git a/app/routes/leagues/__tests__/draft-queue-access.test.ts b/app/routes/leagues/__tests__/draft-queue-access.test.ts
new file mode 100644
index 0000000..c5969d5
--- /dev/null
+++ b/app/routes/leagues/__tests__/draft-queue-access.test.ts
@@ -0,0 +1,213 @@
+import { describe, it, expect } from 'vitest';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+const createMockSeason = (overrides: Partial<{
+ id: string;
+ leagueId: string;
+ status: 'pre_draft' | 'draft' | 'active' | 'completed';
+}> = {}) => ({
+ id: 'season-1',
+ leagueId: 'league-1',
+ year: 2025,
+ status: 'pre_draft' as const,
+ templateId: null,
+ draftRounds: 20,
+ flexSpots: 0,
+ draftDateTime: null,
+ draftInitialTime: 120,
+ draftIncrementTime: 15,
+ currentPickNumber: null,
+ draftStartedAt: null,
+ draftPaused: false,
+ inviteCode: 'TEST123',
+ pointsFor1st: 100,
+ pointsFor2nd: 70,
+ pointsFor3rd: 50,
+ pointsFor4th: 40,
+ pointsFor5th: 25,
+ pointsFor6th: 25,
+ pointsFor7th: 15,
+ pointsFor8th: 15,
+ createdAt: new Date('2025-01-01'),
+ updatedAt: new Date('2025-01-01'),
+ ...overrides,
+});
+
+type MockSeason = ReturnType;
+
+// Mirrors the access-control and redirect logic from the pre-draft rankings loader.
+// Returns null for a successful load, a redirect URL, or an error object.
+function checkPreDraftRankingsAccess(opts: {
+ leagueId: string;
+ season: MockSeason;
+ userId: string | null;
+ hasTeam: boolean;
+}): { redirect: string } | { status: number; message: string } | null {
+ const { leagueId, season, userId, hasTeam } = opts;
+ const { id: seasonId } = season;
+
+ 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) {
+ return { status: 401, message: 'You must be logged in to set pre-draft rankings' };
+ }
+
+ if (!hasTeam) {
+ return { status: 403, message: 'You do not have a team in this season' };
+ }
+
+ return null;
+}
+
+// ---------------------------------------------------------------------------
+// Redirect behaviour based on season status
+// ---------------------------------------------------------------------------
+
+describe('Pre-Draft Rankings Access - Season Status Redirects', () => {
+ it('redirects to the draft room when status is draft', () => {
+ const season = createMockSeason({ status: 'draft' });
+ const result = checkPreDraftRankingsAccess({
+ leagueId: 'league-1',
+ season,
+ userId: 'user-1',
+ hasTeam: true,
+ });
+ expect(result).toEqual({ redirect: '/leagues/league-1/draft/season-1' });
+ });
+
+ it('redirects to the draft board when status is active', () => {
+ const season = createMockSeason({ status: 'active' });
+ const result = checkPreDraftRankingsAccess({
+ leagueId: 'league-1',
+ season,
+ userId: 'user-1',
+ hasTeam: true,
+ });
+ expect(result).toEqual({ redirect: '/leagues/league-1/draft-board/season-1' });
+ });
+
+ it('redirects to the draft board when status is completed', () => {
+ const season = createMockSeason({ status: 'completed' });
+ const result = checkPreDraftRankingsAccess({
+ leagueId: 'league-1',
+ season,
+ userId: 'user-1',
+ hasTeam: true,
+ });
+ expect(result).toEqual({ redirect: '/leagues/league-1/draft-board/season-1' });
+ });
+
+ it('does not redirect when status is pre_draft', () => {
+ const season = createMockSeason({ status: 'pre_draft' });
+ const result = checkPreDraftRankingsAccess({
+ leagueId: 'league-1',
+ season,
+ userId: 'user-1',
+ hasTeam: true,
+ });
+ expect(result).toBeNull();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Authentication
+// ---------------------------------------------------------------------------
+
+describe('Pre-Draft Rankings Access - Authentication', () => {
+ it('returns 401 when no user is logged in', () => {
+ const season = createMockSeason({ status: 'pre_draft' });
+ const result = checkPreDraftRankingsAccess({
+ leagueId: 'league-1',
+ season,
+ userId: null,
+ hasTeam: false,
+ });
+ expect(result).toMatchObject({ status: 401 });
+ });
+
+ it('uses a descriptive 401 message', () => {
+ const season = createMockSeason({ status: 'pre_draft' });
+ const result = checkPreDraftRankingsAccess({
+ leagueId: 'league-1',
+ season,
+ userId: null,
+ hasTeam: false,
+ });
+ expect(result).toMatchObject({ message: 'You must be logged in to set pre-draft rankings' });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Team membership
+// ---------------------------------------------------------------------------
+
+describe('Pre-Draft Rankings Access - Team Membership', () => {
+ it('returns 403 for a logged-in user with no team in the season', () => {
+ const season = createMockSeason({ status: 'pre_draft' });
+ const result = checkPreDraftRankingsAccess({
+ leagueId: 'league-1',
+ season,
+ userId: 'outsider',
+ hasTeam: false,
+ });
+ expect(result).toMatchObject({ status: 403 });
+ });
+
+ it('uses a descriptive 403 message', () => {
+ const season = createMockSeason({ status: 'pre_draft' });
+ const result = checkPreDraftRankingsAccess({
+ leagueId: 'league-1',
+ season,
+ userId: 'outsider',
+ hasTeam: false,
+ });
+ expect(result).toMatchObject({ message: 'You do not have a team in this season' });
+ });
+
+ it('grants access to a logged-in user who owns a team', () => {
+ const season = createMockSeason({ status: 'pre_draft' });
+ const result = checkPreDraftRankingsAccess({
+ leagueId: 'league-1',
+ season,
+ userId: 'owner-user',
+ hasTeam: true,
+ });
+ expect(result).toBeNull();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Redirect targets use the correct leagueId and seasonId
+// ---------------------------------------------------------------------------
+
+describe('Pre-Draft Rankings Access - Redirect URL Correctness', () => {
+ it('uses the leagueId from params in the redirect URL', () => {
+ const season = createMockSeason({ id: 'season-99', status: 'draft' });
+ const result = checkPreDraftRankingsAccess({
+ leagueId: 'league-42',
+ season,
+ userId: 'user-1',
+ hasTeam: true,
+ });
+ expect(result).toEqual({ redirect: '/leagues/league-42/draft/season-99' });
+ });
+
+ it('uses the seasonId in the redirect URL', () => {
+ const season = createMockSeason({ id: 'season-77', status: 'active' });
+ const result = checkPreDraftRankingsAccess({
+ leagueId: 'league-1',
+ season,
+ userId: 'user-1',
+ hasTeam: true,
+ });
+ expect(result).toEqual({ redirect: '/leagues/league-1/draft-board/season-77' });
+ });
+});