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' }); + }); +});