diff --git a/app/components/__tests__/AvailableParticipantsSection.test.tsx b/app/components/__tests__/AvailableParticipantsSection.test.tsx index 5c6c433..a3b02a1 100644 --- a/app/components/__tests__/AvailableParticipantsSection.test.tsx +++ b/app/components/__tests__/AvailableParticipantsSection.test.tsx @@ -9,9 +9,9 @@ vi.mock("@tanstack/react-virtual", () => ({ estimateSize, }: { count: number; - estimateSize: () => number; + estimateSize: (index: number) => number; }) => { - const size = estimateSize(); + const size = estimateSize(0); const items = Array.from({ length: count }, (_, i) => ({ index: i, start: i * size, @@ -171,6 +171,154 @@ describe("AvailableParticipantsSection", () => { }); }); + describe("Projected Pick Dividers", () => { + const manyParticipants = Array.from({ length: 30 }, (_, i) => ({ + id: String(i + 1), + name: `Player ${i + 1}`, + sport: { id: "s1", name: "NFL" }, + })); + + const manyRanks = new Map( + Array.from({ length: 30 }, (_, i) => [ + String(i + 1), + { overallRank: i + 1, sportRank: i + 1 }, + ]) + ); + + it("renders projected pick divider at correct position", () => { + render( + + ); + + expect(screen.getByText("Projected Round 3 Pick")).toBeInTheDocument(); + expect(screen.getByText("Projected Round 4 Pick")).toBeInTheDocument(); + }); + + it("does not render dividers when projectedPicks is undefined", () => { + render( + + ); + + expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument(); + }); + + it("does not render dividers when projectedPicks is empty", () => { + render( + + ); + + expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument(); + }); + + it("skips dividers where picksFromNow exceeds list length", () => { + render( + + ); + + expect(screen.getByText("Projected Round 3 Pick")).toBeInTheDocument(); + expect(screen.queryByText("Projected Round 10 Pick")).not.toBeInTheDocument(); + }); + + it("skips dividers where picksFromNow is zero or negative", () => { + render( + + ); + + expect(screen.queryByText("Projected Round 1 Pick")).not.toBeInTheDocument(); + expect(screen.getByText("Projected Round 2 Pick")).toBeInTheDocument(); + }); + + it("still renders all participants alongside dividers", () => { + render( + + ); + + expect(screen.getAllByText("Player 1").length).toBeGreaterThan(0); + expect(screen.getAllByText("Player 10").length).toBeGreaterThan(0); + expect(screen.getAllByText("Player 30").length).toBeGreaterThan(0); + }); + + it("suppresses dividers when search query is active", () => { + render( + + ); + + expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument(); + }); + + it("suppresses dividers when sport filter is active", () => { + render( + + ); + + expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument(); + }); + + it("suppresses dividers when hideDrafted is active", () => { + render( + + ); + + expect(screen.queryByText(/Projected Round \d+ Pick/)).not.toBeInTheDocument(); + }); + }); + describe("Sport Filter - sheet/popover", () => { it("opens and shows checkboxes for each sport", async () => { render(); diff --git a/app/components/draft/AvailableParticipantsSection.tsx b/app/components/draft/AvailableParticipantsSection.tsx index 94e5781..b0fac0c 100644 --- a/app/components/draft/AvailableParticipantsSection.tsx +++ b/app/components/draft/AvailableParticipantsSection.tsx @@ -19,6 +19,10 @@ import { } from "~/components/ui/sheet"; import { ListPlus, ListX, ChevronDown } from "lucide-react"; +type ListItem = + | { type: "participant"; index: number; key: string } + | { type: "divider"; round: number; key: string }; + function getParticipantState( participant: { id: string; sport: { id: string } }, draftedParticipantIds: Set, @@ -157,6 +161,7 @@ interface AvailableParticipantsSectionProps { onMakePick: (participantId: string) => void; onAddToQueue: (participantId: string) => void; onRemoveFromQueue: (queueId: string) => void; + projectedPicks?: Array<{ round: number; picksFromNow: number }>; } export const AvailableParticipantsSection = memo(function AvailableParticipantsSection({ @@ -183,6 +188,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS onMakePick, onAddToQueue, onRemoveFromQueue, + projectedPicks, }: AvailableParticipantsSectionProps) { const queueMap = useMemo( () => new Map(queue.map((item) => [item.participantId, item.id])), @@ -247,13 +253,41 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS const hasActiveFilters = sportFilters.length > 0 || hideCompletedSports; + const anyFilterActive = searchQuery !== "" || sportFilters.length > 0 || hideDrafted || hideIneligible || hideCompletedSports; + + const listItems = useMemo(() => { + const items: ListItem[] = []; + if (!projectedPicks || projectedPicks.length === 0 || anyFilterActive) { + for (let i = 0; i < participants.length; i++) { + items.push({ type: "participant", index: i, key: participants[i].id }); + } + return items; + } + const dividersByAfterIndex = new Map(); + for (const pp of projectedPicks) { + if (pp.picksFromNow <= 0) continue; + const afterIndex = pp.picksFromNow - 1; + if (afterIndex >= 0 && afterIndex < participants.length - 1) { + dividersByAfterIndex.set(afterIndex, pp.round); + } + } + for (let i = 0; i < participants.length; i++) { + items.push({ type: "participant", index: i, key: participants[i].id }); + const dividerRound = dividersByAfterIndex.get(i); + if (dividerRound !== undefined) { + items.push({ type: "divider", round: dividerRound, key: `divider-rd${dividerRound}` }); + } + } + return items; + }, [participants, projectedPicks, anyFilterActive]); + const parentRef = useRef(null); const virtualizer = useVirtualizer({ - count: participants.length, + count: listItems.length, getScrollElement: () => parentRef.current, - estimateSize: () => 72, + estimateSize: (index) => listItems[index]?.type === "divider" ? 36 : 72, overscan: 5, - getItemKey: (index) => participants[index]?.id ?? index, + getItemKey: (index) => listItems[index].key, }); const desktopGridClass = hasTeam @@ -400,10 +434,37 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS }} > {virtualizer.getVirtualItems().map((virtualItem) => { - const participant = participants[virtualItem.index]; + const listItem = listItems[virtualItem.index]; + + if (listItem.type === "divider") { + return ( +
+
+
+ + Projected Round {listItem.round} Pick + +
+
+
+ ); + } + + const participant = participants[listItem.index]; const { isDrafted, isInQueue, isEligible, ineligibleReason } = getParticipantState(participant, draftedParticipantIds, queueMap, eligibility); const rank = participantRanks.get(participant.id); + const followsDivider = listItems[virtualItem.index - 1]?.type === "divider"; return (
({ + draftOrder: i + 1, + teamId: `team-${i + 1}`, + })); +} + +describe("getTeamForPick", () => { + const slots = makeSlots(4); + + it("returns correct team for round 1 (forward)", () => { + expect(getTeamForPick(1, slots)?.teamId).toBe("team-1"); + expect(getTeamForPick(2, slots)?.teamId).toBe("team-2"); + expect(getTeamForPick(3, slots)?.teamId).toBe("team-3"); + expect(getTeamForPick(4, slots)?.teamId).toBe("team-4"); + }); + + it("returns correct team for round 2 (snake reversed)", () => { + expect(getTeamForPick(5, slots)?.teamId).toBe("team-4"); + expect(getTeamForPick(6, slots)?.teamId).toBe("team-3"); + expect(getTeamForPick(7, slots)?.teamId).toBe("team-2"); + expect(getTeamForPick(8, slots)?.teamId).toBe("team-1"); + }); + + it("returns correct team for round 3 (forward again)", () => { + expect(getTeamForPick(9, slots)?.teamId).toBe("team-1"); + expect(getTeamForPick(10, slots)?.teamId).toBe("team-2"); + expect(getTeamForPick(11, slots)?.teamId).toBe("team-3"); + expect(getTeamForPick(12, slots)?.teamId).toBe("team-4"); + }); + + it("returns undefined for empty slots", () => { + expect(getTeamForPick(1, [])).toBeUndefined(); + }); +}); + +describe("getProjectedPicks", () => { + it("returns empty array when no draft slots", () => { + const result = getProjectedPicks({ + draftSlots: [], + userTeamId: "team-1", + currentPick: 1, + totalRounds: 10, + existingPicks: [], + }); + expect(result).toEqual([]); + }); + + it("returns empty array when user has no team in slots", () => { + const result = getProjectedPicks({ + draftSlots: makeSlots(4), + userTeamId: "team-999", + currentPick: 1, + totalRounds: 10, + existingPicks: [], + }); + expect(result).toEqual([]); + }); + + describe("4-team snake draft, user at position 1", () => { + const slots = makeSlots(4); + + it("computes picks from start of draft", () => { + const result = getProjectedPicks({ + draftSlots: slots, + userTeamId: "team-1", + currentPick: 1, + totalRounds: 4, + existingPicks: [], + }); + + expect(result).toEqual([ + { round: 1, pickNumber: 1, picksFromNow: 0 }, + { round: 2, pickNumber: 8, picksFromNow: 7 }, + { round: 3, pickNumber: 9, picksFromNow: 8 }, + { round: 4, pickNumber: 16, picksFromNow: 15 }, + ]); + }); + + it("computes picks from mid-draft, skipping past picks", () => { + const result = getProjectedPicks({ + draftSlots: slots, + userTeamId: "team-1", + currentPick: 5, + totalRounds: 4, + existingPicks: [ + { pickNumber: 1, teamId: "team-1" }, + ], + }); + + expect(result).toEqual([ + { round: 2, pickNumber: 8, picksFromNow: 3 }, + { round: 3, pickNumber: 9, picksFromNow: 4 }, + { round: 4, pickNumber: 16, picksFromNow: 11 }, + ]); + }); + + it("skips rounds already picked even if current pick is before them", () => { + const result = getProjectedPicks({ + draftSlots: slots, + userTeamId: "team-1", + currentPick: 1, + totalRounds: 4, + existingPicks: [ + { pickNumber: 1, teamId: "team-1" }, + { pickNumber: 8, teamId: "team-1" }, + ], + }); + + expect(result).toEqual([ + { round: 3, pickNumber: 9, picksFromNow: 8 }, + { round: 4, pickNumber: 16, picksFromNow: 15 }, + ]); + }); + }); + + describe("4-team snake draft, user at position 3", () => { + const slots = makeSlots(4); + + it("computes correct snake positions", () => { + const result = getProjectedPicks({ + draftSlots: slots, + userTeamId: "team-3", + currentPick: 1, + totalRounds: 4, + existingPicks: [], + }); + + expect(result).toEqual([ + { round: 1, pickNumber: 3, picksFromNow: 2 }, + { round: 2, pickNumber: 6, picksFromNow: 5 }, + { round: 3, pickNumber: 11, picksFromNow: 10 }, + { round: 4, pickNumber: 14, picksFromNow: 13 }, + ]); + }); + }); + + describe("4-team snake draft, user at position 4 (last)", () => { + const slots = makeSlots(4); + + it("picks first in round 2 due to snake reversal", () => { + const result = getProjectedPicks({ + draftSlots: slots, + userTeamId: "team-4", + currentPick: 1, + totalRounds: 4, + existingPicks: [], + }); + + expect(result).toEqual([ + { round: 1, pickNumber: 4, picksFromNow: 3 }, + { round: 2, pickNumber: 5, picksFromNow: 4 }, + { round: 3, pickNumber: 12, picksFromNow: 11 }, + { round: 4, pickNumber: 13, picksFromNow: 12 }, + ]); + }); + }); + + it("returns empty when all picks are in the past", () => { + const result = getProjectedPicks({ + draftSlots: makeSlots(4), + userTeamId: "team-1", + currentPick: 17, + totalRounds: 4, + existingPicks: [ + { pickNumber: 1, teamId: "team-1" }, + { pickNumber: 8, teamId: "team-1" }, + { pickNumber: 9, teamId: "team-1" }, + { pickNumber: 16, teamId: "team-1" }, + ], + }); + expect(result).toEqual([]); + }); + + it("handles 8-team snake draft correctly", () => { + const slots = makeSlots(8); + + const result = getProjectedPicks({ + draftSlots: slots, + userTeamId: "team-5", + currentPick: 1, + totalRounds: 3, + existingPicks: [], + }); + + expect(result).toEqual([ + { round: 1, pickNumber: 5, picksFromNow: 4 }, + { round: 2, pickNumber: 12, picksFromNow: 11 }, + { round: 3, pickNumber: 21, picksFromNow: 20 }, + ]); + }); + + it("excludes picks before currentPick", () => { + const result = getProjectedPicks({ + draftSlots: makeSlots(4), + userTeamId: "team-1", + currentPick: 9, + totalRounds: 4, + existingPicks: [], + }); + + expect(result).toEqual([ + { round: 3, pickNumber: 9, picksFromNow: 0 }, + { round: 4, pickNumber: 16, picksFromNow: 7 }, + ]); + }); +}); diff --git a/app/lib/draft-order.ts b/app/lib/draft-order.ts index e474bca..7503245 100644 --- a/app/lib/draft-order.ts +++ b/app/lib/draft-order.ts @@ -16,3 +16,52 @@ export function getTeamForPick( } return draftSlots.find((slot) => slot.draftOrder === pickInRound); } + +export interface ProjectedPick { + round: number; + pickNumber: number; + picksFromNow: number; +} + +export function getProjectedPicks(options: { + draftSlots: Array<{ draftOrder: number; teamId: string }>; + userTeamId: string; + currentPick: number; + totalRounds: number; + existingPicks: Array<{ pickNumber: number; teamId: string }>; +}): ProjectedPick[] { + const { draftSlots, userTeamId, currentPick, totalRounds, existingPicks } = options; + const totalTeams = draftSlots.length; + if (totalTeams === 0) return []; + + const userSlot = draftSlots.find((slot) => slot.teamId === userTeamId); + if (!userSlot) return []; + + const userDraftOrder = userSlot.draftOrder; + const pickedNumbersForTeam = new Set( + existingPicks + .filter((p) => p.teamId === userTeamId) + .map((p) => p.pickNumber) + ); + + const result: ProjectedPick[] = []; + + for (let round = 1; round <= totalRounds; round++) { + const isEvenRound = round % 2 === 0; + const pickInRound = isEvenRound + ? totalTeams - userDraftOrder + 1 + : userDraftOrder; + const pickNumber = (round - 1) * totalTeams + pickInRound; + + if (pickNumber < currentPick) continue; + if (pickedNumbersForTeam.has(pickNumber)) continue; + + result.push({ + round, + pickNumber, + picksFromNow: pickNumber - currentPick, + }); + } + + return result; +} diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index af39dac..5633dbc 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -35,7 +35,7 @@ import { ConnectionOverlay } from "~/components/draft/ConnectionOverlay"; import { AuthRecoveryOverlay } from "~/components/draft/AuthRecoveryOverlay"; import { ParticipantSelectionDialog } from "~/components/draft/ParticipantSelectionDialog"; import { calculateDraftEligibility } from "~/lib/draft-eligibility"; -import { getTeamForPick } from "~/lib/draft-order"; +import { getTeamForPick, getProjectedPicks } from "~/lib/draft-order"; import { useDraftNotifications } from "~/hooks/useDraftNotifications"; import { NotificationSettings } from "~/components/NotificationSettings"; import { AutodraftBadgeWithPopover } from "~/components/AutodraftSettings"; @@ -857,6 +857,20 @@ export default function DraftRoom() { return grid; }, [picks, draftSlots, season.draftRounds]); + const projectedPicks = useMemo(() => { + if (!userTeam || season.status !== "draft" || isDraftComplete) return []; + return getProjectedPicks({ + draftSlots, + userTeamId: userTeam.id, + currentPick, + totalRounds: season.draftRounds, + existingPicks: picks.map((p) => ({ + pickNumber: p.pickNumber, + teamId: p.team.id, + })), + }); + }, [userTeam, season.status, isDraftComplete, draftSlots, currentPick, season.draftRounds, picks]); + // Get drafted participant IDs for filtering const draftedParticipantIds = useMemo( () => new Set(picks.map((p) => p.participant.id)), @@ -974,7 +988,8 @@ export default function DraftRoom() { onMakePick: handleMakePick, onAddToQueue: handleAddToQueue, onRemoveFromQueue: handleRemoveFromQueue, - }), [filteredParticipants, participantRanks, miniDraftGrid, searchQuery, sportFilters, hideDrafted, hideIneligible, uniqueSports, draftedParticipantIds, queue, eligibility, canPick, userTeam, hideCompletedSports, userDraftedSportNames, handleHideCompletedSportsChange, handleMakePick, handleAddToQueue, handleRemoveFromQueue, setSearchQuery, setSportFilters, setHideDrafted, setHideIneligible]); + projectedPicks, + }), [filteredParticipants, participantRanks, miniDraftGrid, searchQuery, sportFilters, hideDrafted, hideIneligible, uniqueSports, draftedParticipantIds, queue, eligibility, canPick, userTeam, hideCompletedSports, userDraftedSportNames, handleHideCompletedSportsChange, handleMakePick, handleAddToQueue, handleRemoveFromQueue, setSearchQuery, setSportFilters, setHideDrafted, setHideIneligible, projectedPicks]); const draftGridSectionProps = { draftSlots,