From 973e56142fa092bf81cab0b591a9034bf3fc5ea8 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Fri, 15 May 2026 17:26:36 -0700 Subject: [PATCH] Add pre-draft queue builder so users can rank players before draft order is set (#435) * 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 * Improve pre-draft rankings UX: mobile tabs, add/remove toggle, rename On mobile, show tabs ("All Players" / "My Rankings") instead of a stacked layout that buries the queue below a long participant list. On the All Players list, the button now toggles between Add and Remove so users never need to switch tabs just to drop someone. Desktop keeps the side-by-side panel layout. Also renames the feature throughout from "queue builder" to "pre-draft rankings" and the DraftInfoCard button to "Set Pre-Draft Rankings". https://claude.ai/code/session_01Gu2DkTWL3nv74EMuGPpxhG * Fix all code review issues in pre-draft rankings feature - Replace useFetcher with direct fetch() for add/remove/reorder so rapid clicks no longer cancel in-flight requests - Add toast.error() on all failure paths (matching live draft room pattern) and revert optimistic state when operations fail - Add useRef to give handleReorder a stable reference without a localQueue closure dependency, preventing unnecessary QueueSection re-renders - Convert allPlayersPanel and rankingsPanel to useMemo - Remove leagueId from loader return; read from useParams() instead - Extract queueBuilderHref to a variable in the league home component - Add emptyMessage prop to QueueSection with a correct message for the pre-draft context ("Add players from All Players...") - Add draft-queue-access.test.ts covering all loader access control paths: 401/403 errors, status-based redirects, and redirect URL correctness https://claude.ai/code/session_01Gu2DkTWL3nv74EMuGPpxhG * Fix lint: use !== null check instead of != null oxlint enforces eqeqeq; replace != null with !== null && !== undefined. https://claude.ai/code/session_01Gu2DkTWL3nv74EMuGPpxhG --------- Co-authored-by: Claude --- app/components/draft/QueueSection.tsx | 4 +- app/components/league/DraftInfoCard.tsx | 11 +- app/routes.ts | 4 + .../$leagueId.draft-queue.$seasonId.tsx | 327 ++++++++++++++++++ app/routes/leagues/$leagueId.tsx | 5 + .../__tests__/draft-queue-access.test.ts | 213 ++++++++++++ 6 files changed, 561 insertions(+), 3 deletions(-) create mode 100644 app/routes/leagues/$leagueId.draft-queue.$seasonId.tsx create mode 100644 app/routes/leagues/__tests__/draft-queue-access.test.ts 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' }); + }); +});