Show why a participant is ineligible to draft (#378)
Closes #72 Upgrades ineligibleReasons from Record<string, string> 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 <noreply@anthropic.com>
This commit is contained in:
parent
5b03b22267
commit
049ec8a596
10 changed files with 214 additions and 40 deletions
|
|
@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, within } from "@testing-library/react";
|
import { render, screen, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection";
|
import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection";
|
||||||
|
import type { DraftIneligibilityReason } from "~/lib/draft-eligibility";
|
||||||
|
|
||||||
vi.mock("@tanstack/react-virtual", () => ({
|
vi.mock("@tanstack/react-virtual", () => ({
|
||||||
useVirtualizer: ({
|
useVirtualizer: ({
|
||||||
|
|
@ -67,6 +68,13 @@ const defaultProps = {
|
||||||
// (CSS breakpoints are not applied). Click the first one to open the Sheet.
|
// (CSS breakpoints are not applied). Click the first one to open the Sheet.
|
||||||
let user: ReturnType<typeof userEvent.setup>;
|
let user: ReturnType<typeof userEvent.setup>;
|
||||||
|
|
||||||
|
function createReason(
|
||||||
|
code: DraftIneligibilityReason["code"],
|
||||||
|
message: string
|
||||||
|
): DraftIneligibilityReason {
|
||||||
|
return { code, message };
|
||||||
|
}
|
||||||
|
|
||||||
function clickFirstSportFilterTrigger(name: string | RegExp) {
|
function clickFirstSportFilterTrigger(name: string | RegExp) {
|
||||||
return user.click(screen.getAllByRole("button", { name })[0]);
|
return user.click(screen.getAllByRole("button", { name })[0]);
|
||||||
}
|
}
|
||||||
|
|
@ -506,4 +514,43 @@ describe("AvailableParticipantsSection", () => {
|
||||||
expect(onShowOnlyWatchedChange).toHaveBeenCalledWith(true);
|
expect(onShowOnlyWatchedChange).toHaveBeenCalledWith(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Ineligible reasons", () => {
|
||||||
|
it("renders inline out-of-flex-spots copy for ineligible participants", () => {
|
||||||
|
render(
|
||||||
|
<AvailableParticipantsSection
|
||||||
|
{...defaultProps}
|
||||||
|
hasTeam={true}
|
||||||
|
canPick={true}
|
||||||
|
eligibility={{
|
||||||
|
eligibleSportIds: new Set(["s2"]),
|
||||||
|
ineligibleReasons: {
|
||||||
|
s1: createReason("no_flex_spots", "Out of flex spots (2/2)"),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<AvailableParticipantsSection
|
||||||
|
{...defaultProps}
|
||||||
|
hasTeam={true}
|
||||||
|
canPick={true}
|
||||||
|
eligibility={{
|
||||||
|
eligibleSportIds: new Set(["s2"]),
|
||||||
|
ineligibleReasons: {
|
||||||
|
s1: createReason("no_extra_participants", "Not enough NFL participants for remaining teams"),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getAllByText("Not enough NFL participants for remaining teams").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
59
app/components/__tests__/ParticipantSelectionDialog.test.tsx
Normal file
59
app/components/__tests__/ParticipantSelectionDialog.test.tsx
Normal file
|
|
@ -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<string>(),
|
||||||
|
eligibility: null,
|
||||||
|
uniqueSports: ["NFL", "NBA"],
|
||||||
|
onSelect: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ParticipantSelectionDialog", () => {
|
||||||
|
it("renders inline out-of-flex-spots reason and disables drafting", () => {
|
||||||
|
render(
|
||||||
|
<ParticipantSelectionDialog
|
||||||
|
{...defaultProps}
|
||||||
|
eligibility={{
|
||||||
|
eligibleSportIds: new Set(["s2"]),
|
||||||
|
ineligibleReasons: {
|
||||||
|
s1: {
|
||||||
|
code: "no_flex_spots",
|
||||||
|
message: "Out of flex spots (2/2)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<ParticipantSelectionDialog
|
||||||
|
{...defaultProps}
|
||||||
|
eligibility={{
|
||||||
|
eligibleSportIds: new Set(["s2"]),
|
||||||
|
ineligibleReasons: {
|
||||||
|
s1: {
|
||||||
|
code: "no_extra_participants",
|
||||||
|
message: "Not enough NFL participants for remaining teams",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Not enough NFL participants for remaining teams")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -4,6 +4,7 @@ import { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Checkbox } from "~/components/ui/checkbox";
|
import { Checkbox } from "~/components/ui/checkbox";
|
||||||
import { MiniDraftGrid, type MiniDraftGridProps } from "~/components/draft/MiniDraftGrid";
|
import { MiniDraftGrid, type MiniDraftGridProps } from "~/components/draft/MiniDraftGrid";
|
||||||
|
import type { DraftIneligibilityReason } from "~/lib/draft-eligibility";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
|
|
@ -29,7 +30,7 @@ function getParticipantState(
|
||||||
queueMap: Map<string, string>,
|
queueMap: Map<string, string>,
|
||||||
eligibility: {
|
eligibility: {
|
||||||
eligibleSportIds: Set<string>;
|
eligibleSportIds: Set<string>;
|
||||||
ineligibleReasons: Record<string, string>;
|
ineligibleReasons: Record<string, DraftIneligibilityReason>;
|
||||||
} | null,
|
} | null,
|
||||||
watchedParticipantIds: Set<string>
|
watchedParticipantIds: Set<string>
|
||||||
) {
|
) {
|
||||||
|
|
@ -38,7 +39,7 @@ function getParticipantState(
|
||||||
const isEligible = eligibility
|
const isEligible = eligibility
|
||||||
? eligibility.eligibleSportIds.has(participant.sport.id)
|
? eligibility.eligibleSportIds.has(participant.sport.id)
|
||||||
: true;
|
: true;
|
||||||
const ineligibleReason = eligibility?.ineligibleReasons[participant.sport.id];
|
const ineligibleReason = eligibility?.ineligibleReasons[participant.sport.id] ?? null;
|
||||||
const isWatched = watchedParticipantIds.has(participant.id);
|
const isWatched = watchedParticipantIds.has(participant.id);
|
||||||
return { isDrafted, isInQueue, isEligible, ineligibleReason, isWatched };
|
return { isDrafted, isInQueue, isEligible, ineligibleReason, isWatched };
|
||||||
}
|
}
|
||||||
|
|
@ -151,7 +152,7 @@ interface AvailableParticipantsSectionProps {
|
||||||
queue: Array<{ id: string; participantId: string }>;
|
queue: Array<{ id: string; participantId: string }>;
|
||||||
eligibility: {
|
eligibility: {
|
||||||
eligibleSportIds: Set<string>;
|
eligibleSportIds: Set<string>;
|
||||||
ineligibleReasons: Record<string, string>;
|
ineligibleReasons: Record<string, DraftIneligibilityReason>;
|
||||||
} | null;
|
} | null;
|
||||||
canPick: boolean;
|
canPick: boolean;
|
||||||
hasTeam: boolean;
|
hasTeam: boolean;
|
||||||
|
|
@ -547,11 +548,16 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
||||||
<Badge
|
<Badge
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
title={ineligibleReason}
|
title={ineligibleReason?.message}
|
||||||
>
|
>
|
||||||
Ineligible
|
Ineligible
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{!isDrafted && !isEligible && ineligibleReason && (
|
||||||
|
<p className="text-xs text-destructive/90">
|
||||||
|
{ineligibleReason.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{isInQueue && !isDrafted && isEligible && (
|
{isInQueue && !isDrafted && isEligible && (
|
||||||
<Badge variant="default" className="text-xs">
|
<Badge variant="default" className="text-xs">
|
||||||
Queued
|
Queued
|
||||||
|
|
@ -578,7 +584,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
||||||
size="sm"
|
size="sm"
|
||||||
className="min-h-[44px] min-w-[44px]"
|
className="min-h-[44px] min-w-[44px]"
|
||||||
onClick={() => onAddToQueue(participant.id)}
|
onClick={() => onAddToQueue(participant.id)}
|
||||||
title={!isEligible ? ineligibleReason : "Add to queue"}
|
title={!isEligible ? ineligibleReason?.message : "Add to queue"}
|
||||||
disabled={!isEligible}
|
disabled={!isEligible}
|
||||||
>
|
>
|
||||||
<ListPlus className="h-4 w-4" />
|
<ListPlus className="h-4 w-4" />
|
||||||
|
|
@ -607,7 +613,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
||||||
!canPick
|
!canPick
|
||||||
? "Not your turn"
|
? "Not your turn"
|
||||||
: !isEligible
|
: !isEligible
|
||||||
? ineligibleReason
|
? ineligibleReason?.message
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -631,7 +637,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
||||||
: "hover:bg-muted/50"
|
: "hover:bg-muted/50"
|
||||||
}`}
|
}`}
|
||||||
title={
|
title={
|
||||||
!isEligible && !isDrafted ? ineligibleReason : undefined
|
!isEligible && !isDrafted ? ineligibleReason?.message : undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="text-center text-muted-foreground tabular-nums p-3 px-0">
|
<span className="text-center text-muted-foreground tabular-nums p-3 px-0">
|
||||||
|
|
@ -659,7 +665,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
||||||
<Badge
|
<Badge
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
title={ineligibleReason}
|
title={ineligibleReason?.message}
|
||||||
>
|
>
|
||||||
Ineligible
|
Ineligible
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
@ -669,6 +675,11 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
||||||
Queued
|
Queued
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{!isDrafted && !isEligible && ineligibleReason && (
|
||||||
|
<p className="mt-1 text-xs text-destructive/90">
|
||||||
|
{ineligibleReason.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{hasTeam && (
|
{hasTeam && (
|
||||||
|
|
@ -693,7 +704,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
||||||
onClick={() => onAddToQueue(participant.id)}
|
onClick={() => onAddToQueue(participant.id)}
|
||||||
title={
|
title={
|
||||||
!isEligible
|
!isEligible
|
||||||
? ineligibleReason
|
? ineligibleReason?.message
|
||||||
: "Add to queue"
|
: "Add to queue"
|
||||||
}
|
}
|
||||||
disabled={!isEligible}
|
disabled={!isEligible}
|
||||||
|
|
@ -722,7 +733,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
|
||||||
!canPick
|
!canPick
|
||||||
? "Not your turn"
|
? "Not your turn"
|
||||||
: !isEligible
|
: !isEligible
|
||||||
? ineligibleReason
|
? ineligibleReason?.message
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState, useEffect, useMemo } from "react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import type { DraftIneligibilityReason } from "~/lib/draft-eligibility";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -25,7 +26,7 @@ interface ParticipantSelectionDialogProps {
|
||||||
allowParticipantId?: string;
|
allowParticipantId?: string;
|
||||||
eligibility: {
|
eligibility: {
|
||||||
eligibleSportIds: Set<string>;
|
eligibleSportIds: Set<string>;
|
||||||
ineligibleReasons: Record<string, string>;
|
ineligibleReasons: Record<string, DraftIneligibilityReason>;
|
||||||
} | null;
|
} | null;
|
||||||
/** Marks a row with "Current" badge and "Keep" button label */
|
/** Marks a row with "Current" badge and "Keep" button label */
|
||||||
currentParticipantId?: string;
|
currentParticipantId?: string;
|
||||||
|
|
@ -121,7 +122,7 @@ export function ParticipantSelectionDialog({
|
||||||
? eligibility.eligibleSportIds.has(participant.sport.id)
|
? eligibility.eligibleSportIds.has(participant.sport.id)
|
||||||
: true;
|
: true;
|
||||||
const ineligibleReason =
|
const ineligibleReason =
|
||||||
eligibility?.ineligibleReasons[participant.sport.id];
|
eligibility?.ineligibleReasons[participant.sport.id] ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
|
|
@ -131,7 +132,7 @@ export function ParticipantSelectionDialog({
|
||||||
? "hover:bg-muted/50"
|
? "hover:bg-muted/50"
|
||||||
: "bg-destructive/10 opacity-60"
|
: "bg-destructive/10 opacity-60"
|
||||||
}`}
|
}`}
|
||||||
title={!isEligible ? ineligibleReason : undefined}
|
title={!isEligible ? ineligibleReason?.message : undefined}
|
||||||
>
|
>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -149,12 +150,17 @@ export function ParticipantSelectionDialog({
|
||||||
<Badge
|
<Badge
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
title={ineligibleReason}
|
title={ineligibleReason?.message}
|
||||||
>
|
>
|
||||||
Ineligible
|
Ineligible
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!isEligible && ineligibleReason && (
|
||||||
|
<p className="mt-1 text-xs text-destructive/90">
|
||||||
|
{ineligibleReason.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-muted-foreground">
|
<td className="p-3 text-muted-foreground">
|
||||||
{participant.sport.name}
|
{participant.sport.name}
|
||||||
|
|
@ -164,7 +170,7 @@ export function ParticipantSelectionDialog({
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onSelect(participant.id)}
|
onClick={() => onSelect(participant.id)}
|
||||||
disabled={!isEligible}
|
disabled={!isEligible}
|
||||||
title={!isEligible ? ineligibleReason : undefined}
|
title={!isEligible ? ineligibleReason?.message : undefined}
|
||||||
>
|
>
|
||||||
{isCurrentPick ? "Keep" : "Draft"}
|
{isCurrentPick ? "Keep" : "Draft"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -117,8 +117,14 @@ describe("calculateDraftEligibility", () => {
|
||||||
expect(eligibility.flexPicksAvailable).toBe(2);
|
expect(eligibility.flexPicksAvailable).toBe(2);
|
||||||
// Can only draft from NHL (not yet drafted)
|
// Can only draft from NHL (not yet drafted)
|
||||||
expect(eligibility.eligibleSportIds).toEqual(new Set(["nhl"]));
|
expect(eligibility.eligibleSportIds).toEqual(new Set(["nhl"]));
|
||||||
expect(eligibility.ineligibleReasons["nfl"]).toContain("All flex picks used");
|
expect(eligibility.ineligibleReasons["nfl"]).toMatchObject({
|
||||||
expect(eligibility.ineligibleReasons["nba"]).toContain("All flex picks used");
|
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
|
// 2 NFL participants left, 2 teams still need NFL
|
||||||
// For flex pick: 2 > 2 is FALSE, so blocked
|
// For flex pick: 2 > 2 is FALSE, so blocked
|
||||||
expect(eligibility.eligibleSportIds).not.toContain("nfl");
|
expect(eligibility.eligibleSportIds).not.toContain("nfl");
|
||||||
expect(eligibility.ineligibleReasons["nfl"]).toContain(
|
expect(eligibility.ineligibleReasons["nfl"]).toMatchObject({
|
||||||
"Would prevent other teams from getting NFL"
|
code: "no_extra_participants",
|
||||||
);
|
message: "Not enough NFL participants for remaining teams",
|
||||||
|
});
|
||||||
expect(eligibility.sportAvailability["nfl"].canDraftAsFlex).toBe(false);
|
expect(eligibility.sportAvailability["nfl"].canDraftAsFlex).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -286,9 +293,9 @@ describe("calculateDraftEligibility", () => {
|
||||||
// Even though NFL has 3 > 4 (global constraint would block flex),
|
// Even though NFL has 3 > 4 (global constraint would block flex),
|
||||||
// team can't draft NFL because flex picks are exhausted
|
// team can't draft NFL because flex picks are exhausted
|
||||||
expect(eligibility.eligibleSportIds).toEqual(new Set([]));
|
expect(eligibility.eligibleSportIds).toEqual(new Set([]));
|
||||||
expect(eligibility.ineligibleReasons["nfl"]).toContain("All flex picks used");
|
expect(eligibility.ineligibleReasons["nfl"].code).toBe("no_flex_spots");
|
||||||
expect(eligibility.ineligibleReasons["nba"]).toContain("All flex picks used");
|
expect(eligibility.ineligibleReasons["nba"].code).toBe("no_flex_spots");
|
||||||
expect(eligibility.ineligibleReasons["nhl"]).toContain("All flex picks used");
|
expect(eligibility.ineligibleReasons["nhl"].code).toBe("no_flex_spots");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows pick when both constraints satisfied", () => {
|
it("allows pick when both constraints satisfied", () => {
|
||||||
|
|
@ -401,9 +408,10 @@ describe("calculateDraftEligibility", () => {
|
||||||
// 3 participants, 5 teams need it, 3 < 5
|
// 3 participants, 5 teams need it, 3 < 5
|
||||||
expect(eligibility.sportAvailability["nfl"].canDraftAsFirst).toBe(false);
|
expect(eligibility.sportAvailability["nfl"].canDraftAsFirst).toBe(false);
|
||||||
expect(eligibility.eligibleSportIds).not.toContain("nfl");
|
expect(eligibility.eligibleSportIds).not.toContain("nfl");
|
||||||
expect(eligibility.ineligibleReasons["nfl"]).toContain(
|
expect(eligibility.ineligibleReasons["nfl"]).toMatchObject({
|
||||||
"Only 3 participants left for 5 teams"
|
code: "unavailable",
|
||||||
);
|
message: "Only 3 NFL participants left for 5 teams",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -580,8 +588,8 @@ describe("calculateDraftEligibility", () => {
|
||||||
|
|
||||||
// Can only draft NHL (not yet picked)
|
// Can only draft NHL (not yet picked)
|
||||||
expect(eligibility.eligibleSportIds).toEqual(new Set(["nhl"]));
|
expect(eligibility.eligibleSportIds).toEqual(new Set(["nhl"]));
|
||||||
expect(eligibility.ineligibleReasons["nfl"]).toContain("All flex picks used");
|
expect(eligibility.ineligibleReasons["nfl"].code).toBe("no_flex_spots");
|
||||||
expect(eligibility.ineligibleReasons["nba"]).toContain("All flex picks used");
|
expect(eligibility.ineligibleReasons["nba"].code).toBe("no_flex_spots");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,20 @@ export interface SportAvailability {
|
||||||
canDraftAsFlex: boolean; // undraftedCount > teamsStillNeeding
|
canDraftAsFlex: boolean; // undraftedCount > teamsStillNeeding
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DraftIneligibilityCode =
|
||||||
|
| "no_flex_spots"
|
||||||
|
| "no_extra_participants"
|
||||||
|
| "unavailable";
|
||||||
|
|
||||||
|
export interface DraftIneligibilityReason {
|
||||||
|
code: DraftIneligibilityCode;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DraftEligibility {
|
export interface DraftEligibility {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
eligibleSportIds: Set<string>;
|
eligibleSportIds: Set<string>;
|
||||||
ineligibleReasons: Record<string, string>; // sportId -> reason
|
ineligibleReasons: Record<string, DraftIneligibilityReason>; // sportId -> reason
|
||||||
flexPicksUsed: number;
|
flexPicksUsed: number;
|
||||||
flexPicksAvailable: number;
|
flexPicksAvailable: number;
|
||||||
picksBySport: Record<string, number>;
|
picksBySport: Record<string, number>;
|
||||||
|
|
@ -56,6 +66,34 @@ interface TeamData {
|
||||||
id: string;
|
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:
|
* Calculate which sports a team is eligible to draft from based on:
|
||||||
* - Team's flex pick usage
|
* - Team's flex pick usage
|
||||||
|
|
@ -132,7 +170,7 @@ export function calculateDraftEligibility(
|
||||||
|
|
||||||
// Step 3: Combine constraints to determine eligibility
|
// Step 3: Combine constraints to determine eligibility
|
||||||
const eligibleSportIds = new Set<string>();
|
const eligibleSportIds = new Set<string>();
|
||||||
const ineligibleReasons: Record<string, string> = {};
|
const ineligibleReasons: Record<string, DraftIneligibilityReason> = {};
|
||||||
|
|
||||||
for (const sport of seasonSports) {
|
for (const sport of seasonSports) {
|
||||||
const sportId = sport.id;
|
const sportId = sport.id;
|
||||||
|
|
@ -140,21 +178,21 @@ export function calculateDraftEligibility(
|
||||||
const currentTeamHasSport = (picksBySport[sportId] || 0) > 0;
|
const currentTeamHasSport = (picksBySport[sportId] || 0) > 0;
|
||||||
|
|
||||||
let isEligible = false;
|
let isEligible = false;
|
||||||
let reason = "";
|
let reason: DraftIneligibilityReason | null = null;
|
||||||
|
|
||||||
if (!currentTeamHasSport) {
|
if (!currentTeamHasSport) {
|
||||||
// Team hasn't drafted from this sport yet (would be first pick)
|
// Team hasn't drafted from this sport yet (would be first pick)
|
||||||
if (availability.canDraftAsFirst) {
|
if (availability.canDraftAsFirst) {
|
||||||
isEligible = true;
|
isEligible = true;
|
||||||
} else {
|
} else {
|
||||||
reason = `Only ${availability.undraftedCount} participants left for ${availability.teamsStillNeeding} teams needing ${sport.name}`;
|
reason = getUnavailableMessage(sport.name, availability.undraftedCount, availability.teamsStillNeeding);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Team has drafted from this sport (would be flex pick)
|
// Team has drafted from this sport (would be flex pick)
|
||||||
if (!hasFlexesRemaining) {
|
if (!hasFlexesRemaining) {
|
||||||
reason = `All flex picks used (${flexPicksUsed}/${flexPicksAvailable})`;
|
reason = getFlexSpotsMessage(flexPicksUsed, flexPicksAvailable);
|
||||||
} else if (!availability.canDraftAsFlex) {
|
} 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 {
|
} else {
|
||||||
isEligible = true;
|
isEligible = true;
|
||||||
}
|
}
|
||||||
|
|
@ -162,7 +200,7 @@ export function calculateDraftEligibility(
|
||||||
|
|
||||||
if (isEligible) {
|
if (isEligible) {
|
||||||
eligibleSportIds.add(sportId);
|
eligibleSportIds.add(sportId);
|
||||||
} else {
|
} else if (reason) {
|
||||||
ineligibleReasons[sportId] = reason;
|
ineligibleReasons[sportId] = reason;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -288,14 +288,19 @@ describe("draft.force-manual-pick action", () => {
|
||||||
const { calculateDraftEligibility } = await import("~/lib/draft-eligibility");
|
const { calculateDraftEligibility } = await import("~/lib/draft-eligibility");
|
||||||
vi.mocked(calculateDraftEligibility).mockReturnValue({
|
vi.mocked(calculateDraftEligibility).mockReturnValue({
|
||||||
eligibleSportIds: new Set(),
|
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);
|
} as any);
|
||||||
|
|
||||||
const response = await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
const response = await action({ request: defaultPickRequest(), params: {}, context: ctx });
|
||||||
|
|
||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(400);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
expect(data.error).toMatch(/sport limit reached/i);
|
expect(data.error).toMatch(/not enough nfl participants/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,7 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
|
|
||||||
const sportId = participant.sportsSeason.sport.id;
|
const sportId = participant.sportsSeason.sport.id;
|
||||||
if (!eligibility.eligibleSportIds.has(sportId)) {
|
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 });
|
return Response.json({ error: reason }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
|
|
||||||
const sportId = participant.sportsSeason.sport.id;
|
const sportId = participant.sportsSeason.sport.id;
|
||||||
if (!eligibility.eligibleSportIds.has(sportId)) {
|
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 });
|
return Response.json({ error: reason }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ export async function action(args: ActionFunctionArgs) {
|
||||||
|
|
||||||
const sportId = participant.sportsSeason.sport.id;
|
const sportId = participant.sportsSeason.sport.id;
|
||||||
if (!eligibility.eligibleSportIds.has(sportId)) {
|
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 });
|
return Response.json({ error: reason }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue