From 26efcedb0a980d55dec4b94876233096ab5f27f7 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Mon, 4 May 2026 21:46:58 -0700 Subject: [PATCH] Show why a participant is ineligible to draft Closes #72 Upgrades ineligibleReasons from Record to a structured {code, message} type so the UI can render the human-readable reason inline below the Ineligible badge in both AvailableParticipantsSection and ParticipantSelectionDialog. API routes surface the message in 400 responses unchanged. Co-Authored-By: Claude Sonnet 4.6 --- .../AvailableParticipantsSection.test.tsx | 47 +++++++++++++++ .../ParticipantSelectionDialog.test.tsx | 59 +++++++++++++++++++ .../draft/AvailableParticipantsSection.tsx | 31 ++++++---- .../draft/ParticipantSelectionDialog.tsx | 16 +++-- app/lib/__tests__/draft-eligibility.test.ts | 34 +++++++---- app/lib/draft-eligibility.ts | 52 +++++++++++++--- .../__tests__/draft.force-manual-pick.test.ts | 9 ++- app/routes/api/draft.force-manual-pick.ts | 2 +- app/routes/api/draft.make-pick.ts | 2 +- app/routes/api/draft.replace-pick.ts | 2 +- 10 files changed, 214 insertions(+), 40 deletions(-) create mode 100644 app/components/__tests__/ParticipantSelectionDialog.test.tsx diff --git a/app/components/__tests__/AvailableParticipantsSection.test.tsx b/app/components/__tests__/AvailableParticipantsSection.test.tsx index 341d8b4..8230fb9 100644 --- a/app/components/__tests__/AvailableParticipantsSection.test.tsx +++ b/app/components/__tests__/AvailableParticipantsSection.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection"; +import type { DraftIneligibilityReason } from "~/lib/draft-eligibility"; vi.mock("@tanstack/react-virtual", () => ({ useVirtualizer: ({ @@ -67,6 +68,13 @@ const defaultProps = { // (CSS breakpoints are not applied). Click the first one to open the Sheet. let user: ReturnType; +function createReason( + code: DraftIneligibilityReason["code"], + message: string +): DraftIneligibilityReason { + return { code, message }; +} + function clickFirstSportFilterTrigger(name: string | RegExp) { return user.click(screen.getAllByRole("button", { name })[0]); } @@ -506,4 +514,43 @@ describe("AvailableParticipantsSection", () => { expect(onShowOnlyWatchedChange).toHaveBeenCalledWith(true); }); }); + + describe("Ineligible reasons", () => { + it("renders inline out-of-flex-spots copy for ineligible participants", () => { + render( + + ); + + expect(screen.getAllByText("Out of flex spots (2/2)").length).toBeGreaterThan(0); + expect(screen.getAllByTitle("Out of flex spots (2/2)").length).toBeGreaterThan(0); + }); + + it("renders inline no-extras copy for ineligible participants", () => { + render( + + ); + + expect(screen.getAllByText("Not enough NFL participants for remaining teams").length).toBeGreaterThan(0); + }); + }); }); diff --git a/app/components/__tests__/ParticipantSelectionDialog.test.tsx b/app/components/__tests__/ParticipantSelectionDialog.test.tsx new file mode 100644 index 0000000..6a8a9a9 --- /dev/null +++ b/app/components/__tests__/ParticipantSelectionDialog.test.tsx @@ -0,0 +1,59 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ParticipantSelectionDialog } from "~/components/draft/ParticipantSelectionDialog"; + +const defaultProps = { + open: true, + onOpenChange: vi.fn(), + title: "Force pick", + description: "Choose a participant", + participants: [ + { id: "1", name: "Player A", sport: { id: "s1", name: "NFL" } }, + { id: "2", name: "Player B", sport: { id: "s2", name: "NBA" } }, + ], + draftedParticipantIds: new Set(), + eligibility: null, + uniqueSports: ["NFL", "NBA"], + onSelect: vi.fn(), +}; + +describe("ParticipantSelectionDialog", () => { + it("renders inline out-of-flex-spots reason and disables drafting", () => { + render( + + ); + + expect(screen.getByText("Out of flex spots (2/2)")).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: "Draft" })[0]).toBeDisabled(); + }); + + it("renders inline no-extras reason for blocked flex picks", () => { + render( + + ); + + expect(screen.getByText("Not enough NFL participants for remaining teams")).toBeInTheDocument(); + }); +}); diff --git a/app/components/draft/AvailableParticipantsSection.tsx b/app/components/draft/AvailableParticipantsSection.tsx index 452f3ae..9280a25 100644 --- a/app/components/draft/AvailableParticipantsSection.tsx +++ b/app/components/draft/AvailableParticipantsSection.tsx @@ -4,6 +4,7 @@ import { Button } from "~/components/ui/button"; import { Badge } from "~/components/ui/badge"; import { Checkbox } from "~/components/ui/checkbox"; import { MiniDraftGrid, type MiniDraftGridProps } from "~/components/draft/MiniDraftGrid"; +import type { DraftIneligibilityReason } from "~/lib/draft-eligibility"; import { Popover, PopoverTrigger, @@ -29,7 +30,7 @@ function getParticipantState( queueMap: Map, eligibility: { eligibleSportIds: Set; - ineligibleReasons: Record; + ineligibleReasons: Record; } | null, watchedParticipantIds: Set ) { @@ -38,7 +39,7 @@ function getParticipantState( const isEligible = eligibility ? eligibility.eligibleSportIds.has(participant.sport.id) : true; - const ineligibleReason = eligibility?.ineligibleReasons[participant.sport.id]; + const ineligibleReason = eligibility?.ineligibleReasons[participant.sport.id] ?? null; const isWatched = watchedParticipantIds.has(participant.id); return { isDrafted, isInQueue, isEligible, ineligibleReason, isWatched }; } @@ -151,7 +152,7 @@ interface AvailableParticipantsSectionProps { queue: Array<{ id: string; participantId: string }>; eligibility: { eligibleSportIds: Set; - ineligibleReasons: Record; + ineligibleReasons: Record; } | null; canPick: boolean; hasTeam: boolean; @@ -547,11 +548,16 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS Ineligible )} + {!isDrafted && !isEligible && ineligibleReason && ( +

+ {ineligibleReason.message} +

+ )} {isInQueue && !isDrafted && isEligible && ( Queued @@ -578,7 +584,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS size="sm" className="min-h-[44px] min-w-[44px]" onClick={() => onAddToQueue(participant.id)} - title={!isEligible ? ineligibleReason : "Add to queue"} + title={!isEligible ? ineligibleReason?.message : "Add to queue"} disabled={!isEligible} > @@ -607,7 +613,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS !canPick ? "Not your turn" : !isEligible - ? ineligibleReason + ? ineligibleReason?.message : undefined } > @@ -631,7 +637,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS : "hover:bg-muted/50" }`} title={ - !isEligible && !isDrafted ? ineligibleReason : undefined + !isEligible && !isDrafted ? ineligibleReason?.message : undefined } > @@ -659,7 +665,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS Ineligible @@ -669,6 +675,11 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS Queued )} + {!isDrafted && !isEligible && ineligibleReason && ( +

+ {ineligibleReason.message} +

+ )} {hasTeam && ( @@ -693,7 +704,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS onClick={() => onAddToQueue(participant.id)} title={ !isEligible - ? ineligibleReason + ? ineligibleReason?.message : "Add to queue" } disabled={!isEligible} @@ -722,7 +733,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS !canPick ? "Not your turn" : !isEligible - ? ineligibleReason + ? ineligibleReason?.message : undefined } > diff --git a/app/components/draft/ParticipantSelectionDialog.tsx b/app/components/draft/ParticipantSelectionDialog.tsx index b7cfe7b..9c50be0 100644 --- a/app/components/draft/ParticipantSelectionDialog.tsx +++ b/app/components/draft/ParticipantSelectionDialog.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useMemo } from "react"; import { Button } from "~/components/ui/button"; import { Badge } from "~/components/ui/badge"; +import type { DraftIneligibilityReason } from "~/lib/draft-eligibility"; import { Dialog, DialogContent, @@ -25,7 +26,7 @@ interface ParticipantSelectionDialogProps { allowParticipantId?: string; eligibility: { eligibleSportIds: Set; - ineligibleReasons: Record; + ineligibleReasons: Record; } | null; /** Marks a row with "Current" badge and "Keep" button label */ currentParticipantId?: string; @@ -121,7 +122,7 @@ export function ParticipantSelectionDialog({ ? eligibility.eligibleSportIds.has(participant.sport.id) : true; const ineligibleReason = - eligibility?.ineligibleReasons[participant.sport.id]; + eligibility?.ineligibleReasons[participant.sport.id] ?? null; return (
@@ -149,12 +150,17 @@ export function ParticipantSelectionDialog({ Ineligible )}
+ {!isEligible && ineligibleReason && ( +

+ {ineligibleReason.message} +

+ )} {participant.sport.name} @@ -164,7 +170,7 @@ export function ParticipantSelectionDialog({ size="sm" onClick={() => onSelect(participant.id)} disabled={!isEligible} - title={!isEligible ? ineligibleReason : undefined} + title={!isEligible ? ineligibleReason?.message : undefined} > {isCurrentPick ? "Keep" : "Draft"} diff --git a/app/lib/__tests__/draft-eligibility.test.ts b/app/lib/__tests__/draft-eligibility.test.ts index bcbe71e..02c3bdc 100644 --- a/app/lib/__tests__/draft-eligibility.test.ts +++ b/app/lib/__tests__/draft-eligibility.test.ts @@ -117,8 +117,14 @@ describe("calculateDraftEligibility", () => { expect(eligibility.flexPicksAvailable).toBe(2); // Can only draft from NHL (not yet drafted) expect(eligibility.eligibleSportIds).toEqual(new Set(["nhl"])); - expect(eligibility.ineligibleReasons["nfl"]).toContain("All flex picks used"); - expect(eligibility.ineligibleReasons["nba"]).toContain("All flex picks used"); + expect(eligibility.ineligibleReasons["nfl"]).toMatchObject({ + code: "no_flex_spots", + message: "Out of flex spots (2/2)", + }); + expect(eligibility.ineligibleReasons["nba"]).toMatchObject({ + code: "no_flex_spots", + message: "Out of flex spots (2/2)", + }); }); }); @@ -181,9 +187,10 @@ describe("calculateDraftEligibility", () => { // 2 NFL participants left, 2 teams still need NFL // For flex pick: 2 > 2 is FALSE, so blocked expect(eligibility.eligibleSportIds).not.toContain("nfl"); - expect(eligibility.ineligibleReasons["nfl"]).toContain( - "Would prevent other teams from getting NFL" - ); + expect(eligibility.ineligibleReasons["nfl"]).toMatchObject({ + code: "no_extra_participants", + message: "Not enough NFL participants for remaining teams", + }); expect(eligibility.sportAvailability["nfl"].canDraftAsFlex).toBe(false); }); @@ -286,9 +293,9 @@ describe("calculateDraftEligibility", () => { // Even though NFL has 3 > 4 (global constraint would block flex), // team can't draft NFL because flex picks are exhausted expect(eligibility.eligibleSportIds).toEqual(new Set([])); - expect(eligibility.ineligibleReasons["nfl"]).toContain("All flex picks used"); - expect(eligibility.ineligibleReasons["nba"]).toContain("All flex picks used"); - expect(eligibility.ineligibleReasons["nhl"]).toContain("All flex picks used"); + expect(eligibility.ineligibleReasons["nfl"].code).toBe("no_flex_spots"); + expect(eligibility.ineligibleReasons["nba"].code).toBe("no_flex_spots"); + expect(eligibility.ineligibleReasons["nhl"].code).toBe("no_flex_spots"); }); it("allows pick when both constraints satisfied", () => { @@ -401,9 +408,10 @@ describe("calculateDraftEligibility", () => { // 3 participants, 5 teams need it, 3 < 5 expect(eligibility.sportAvailability["nfl"].canDraftAsFirst).toBe(false); expect(eligibility.eligibleSportIds).not.toContain("nfl"); - expect(eligibility.ineligibleReasons["nfl"]).toContain( - "Only 3 participants left for 5 teams" - ); + expect(eligibility.ineligibleReasons["nfl"]).toMatchObject({ + code: "unavailable", + message: "Only 3 NFL participants left for 5 teams", + }); }); }); @@ -580,8 +588,8 @@ describe("calculateDraftEligibility", () => { // Can only draft NHL (not yet picked) expect(eligibility.eligibleSportIds).toEqual(new Set(["nhl"])); - expect(eligibility.ineligibleReasons["nfl"]).toContain("All flex picks used"); - expect(eligibility.ineligibleReasons["nba"]).toContain("All flex picks used"); + expect(eligibility.ineligibleReasons["nfl"].code).toBe("no_flex_spots"); + expect(eligibility.ineligibleReasons["nba"].code).toBe("no_flex_spots"); }); }); diff --git a/app/lib/draft-eligibility.ts b/app/lib/draft-eligibility.ts index 4f94774..167b89a 100644 --- a/app/lib/draft-eligibility.ts +++ b/app/lib/draft-eligibility.ts @@ -18,10 +18,20 @@ export interface SportAvailability { canDraftAsFlex: boolean; // undraftedCount > teamsStillNeeding } +export type DraftIneligibilityCode = + | "no_flex_spots" + | "no_extra_participants" + | "unavailable"; + +export interface DraftIneligibilityReason { + code: DraftIneligibilityCode; + message: string; +} + export interface DraftEligibility { teamId: string; eligibleSportIds: Set; - ineligibleReasons: Record; // sportId -> reason + ineligibleReasons: Record; // sportId -> reason flexPicksUsed: number; flexPicksAvailable: number; picksBySport: Record; @@ -56,6 +66,34 @@ interface TeamData { id: string; } +function getFlexSpotsMessage( + flexPicksUsed: number, + flexPicksAvailable: number +): DraftIneligibilityReason { + return { + code: "no_flex_spots", + message: `Out of flex spots (${flexPicksUsed}/${flexPicksAvailable})`, + }; +} + +function getNoExtrasMessage(sportName: string): DraftIneligibilityReason { + return { + code: "no_extra_participants", + message: `Not enough ${sportName} participants for remaining teams`, + }; +} + +function getUnavailableMessage( + sportName: string, + undraftedCount: number, + teamsStillNeeding: number +): DraftIneligibilityReason { + return { + code: "unavailable", + message: `Only ${undraftedCount} ${sportName} participant${undraftedCount === 1 ? "" : "s"} left for ${teamsStillNeeding} team${teamsStillNeeding === 1 ? "" : "s"}`, + }; +} + /** * Calculate which sports a team is eligible to draft from based on: * - Team's flex pick usage @@ -132,7 +170,7 @@ export function calculateDraftEligibility( // Step 3: Combine constraints to determine eligibility const eligibleSportIds = new Set(); - const ineligibleReasons: Record = {}; + const ineligibleReasons: Record = {}; for (const sport of seasonSports) { const sportId = sport.id; @@ -140,21 +178,21 @@ export function calculateDraftEligibility( const currentTeamHasSport = (picksBySport[sportId] || 0) > 0; let isEligible = false; - let reason = ""; + let reason: DraftIneligibilityReason | null = null; if (!currentTeamHasSport) { // Team hasn't drafted from this sport yet (would be first pick) if (availability.canDraftAsFirst) { isEligible = true; } else { - reason = `Only ${availability.undraftedCount} participants left for ${availability.teamsStillNeeding} teams needing ${sport.name}`; + reason = getUnavailableMessage(sport.name, availability.undraftedCount, availability.teamsStillNeeding); } } else { // Team has drafted from this sport (would be flex pick) if (!hasFlexesRemaining) { - reason = `All flex picks used (${flexPicksUsed}/${flexPicksAvailable})`; + reason = getFlexSpotsMessage(flexPicksUsed, flexPicksAvailable); } else if (!availability.canDraftAsFlex) { - reason = `Would prevent other teams from getting ${sport.name} (${availability.undraftedCount} left for ${availability.teamsStillNeeding} teams)`; + reason = getNoExtrasMessage(sport.name); } else { isEligible = true; } @@ -162,7 +200,7 @@ export function calculateDraftEligibility( if (isEligible) { eligibleSportIds.add(sportId); - } else { + } else if (reason) { ineligibleReasons[sportId] = reason; } } diff --git a/app/routes/api/__tests__/draft.force-manual-pick.test.ts b/app/routes/api/__tests__/draft.force-manual-pick.test.ts index bb905dd..3306277 100644 --- a/app/routes/api/__tests__/draft.force-manual-pick.test.ts +++ b/app/routes/api/__tests__/draft.force-manual-pick.test.ts @@ -288,14 +288,19 @@ describe("draft.force-manual-pick action", () => { const { calculateDraftEligibility } = await import("~/lib/draft-eligibility"); vi.mocked(calculateDraftEligibility).mockReturnValue({ eligibleSportIds: new Set(), - ineligibleReasons: { [SPORT_ID]: "Sport limit reached" }, + ineligibleReasons: { + [SPORT_ID]: { + code: "no_extra_participants", + message: "Not enough NFL participants for remaining teams", + }, + }, } as any); const response = await action({ request: defaultPickRequest(), params: {}, context: ctx }); expect(response.status).toBe(400); const data = await response.json(); - expect(data.error).toMatch(/sport limit reached/i); + expect(data.error).toMatch(/not enough nfl participants/i); }); }); diff --git a/app/routes/api/draft.force-manual-pick.ts b/app/routes/api/draft.force-manual-pick.ts index f72e639..6350d8d 100644 --- a/app/routes/api/draft.force-manual-pick.ts +++ b/app/routes/api/draft.force-manual-pick.ts @@ -145,7 +145,7 @@ export async function action(args: ActionFunctionArgs) { const sportId = participant.sportsSeason.sport.id; if (!eligibility.eligibleSportIds.has(sportId)) { - const reason = eligibility.ineligibleReasons[sportId] || "Cannot draft from this sport"; + const reason = eligibility.ineligibleReasons[sportId]?.message || "Cannot draft from this sport"; return Response.json({ error: reason }, { status: 400 }); } diff --git a/app/routes/api/draft.make-pick.ts b/app/routes/api/draft.make-pick.ts index b7a9bf9..d547cf1 100644 --- a/app/routes/api/draft.make-pick.ts +++ b/app/routes/api/draft.make-pick.ts @@ -133,7 +133,7 @@ export async function action(args: ActionFunctionArgs) { const sportId = participant.sportsSeason.sport.id; if (!eligibility.eligibleSportIds.has(sportId)) { - const reason = eligibility.ineligibleReasons[sportId] || "Cannot draft from this sport"; + const reason = eligibility.ineligibleReasons[sportId]?.message || "Cannot draft from this sport"; return Response.json({ error: reason }, { status: 400 }); } diff --git a/app/routes/api/draft.replace-pick.ts b/app/routes/api/draft.replace-pick.ts index b125360..f46247a 100644 --- a/app/routes/api/draft.replace-pick.ts +++ b/app/routes/api/draft.replace-pick.ts @@ -123,7 +123,7 @@ export async function action(args: ActionFunctionArgs) { const sportId = participant.sportsSeason.sport.id; if (!eligibility.eligibleSportIds.has(sportId)) { - const reason = eligibility.ineligibleReasons[sportId] || "Cannot draft from this sport"; + const reason = eligibility.ineligibleReasons[sportId]?.message || "Cannot draft from this sport"; return Response.json({ error: reason }, { status: 400 }); }