Implement Omni League Draft Eligibility Rules

- Created draft eligibility calculation logic in `app/lib/draft-eligibility.ts`
- Added interfaces for sport availability and draft eligibility
- Implemented `calculateDraftEligibility` function to determine eligible sports based on team picks and global availability
- Developed `getEligibilitySummary` function for human-readable eligibility status
- Updated API endpoints to validate eligibility before making picks
- Enhanced frontend draft room UI to reflect eligibility status and provide visual indicators for ineligible participants
- Comprehensive implementation summary and testing documentation added
- All changes type-checked and unit tests passed successfully
This commit is contained in:
Chris Parsons 2025-10-24 21:12:07 -07:00
parent f3050908e8
commit decb28dc19
12 changed files with 2290 additions and 150 deletions

View file

@ -0,0 +1,753 @@
import { describe, it, expect } from "vitest";
import { calculateDraftEligibility, getEligibilitySummary } from "../draft-eligibility";
// Helper to create test data
const createSport = (id: string, name: string) => ({ id, name });
const createTeam = (id: string) => ({ id });
const createParticipant = (id: string, sportId: string, sportName: string) => ({
id,
sport: { id: sportId, name: sportName },
});
const createPick = (
teamId: string,
participantId: string,
sportId: string,
sportName: string
) => ({
teamId,
participant: {
id: participantId,
sport: { id: sportId, name: sportName },
},
});
describe("calculateDraftEligibility", () => {
const NFL = createSport("nfl", "NFL");
const NBA = createSport("nba", "NBA");
const NHL = createSport("nhl", "NHL");
const sports = [NFL, NBA, NHL];
describe("Team Flex Pick Constraints", () => {
it("allows drafting from any sport when no picks made", () => {
const team1 = createTeam("team1");
const teams = [team1];
const participants = [
createParticipant("p1", "nfl", "NFL"),
createParticipant("p2", "nba", "NBA"),
createParticipant("p3", "nhl", "NHL"),
];
const eligibility = calculateDraftEligibility(
"team1",
[], // no picks yet
[],
participants,
sports,
5, // 5 rounds
teams
);
expect(eligibility.eligibleSportIds).toEqual(new Set(["nfl", "nba", "nhl"]));
expect(eligibility.flexPicksUsed).toBe(0);
expect(eligibility.flexPicksAvailable).toBe(2); // 5 rounds - 3 sports
});
it("tracks flex picks correctly when drafting duplicates", () => {
const team1 = createTeam("team1");
const teams = [team1];
const participants = [
createParticipant("p1", "nfl", "NFL"),
createParticipant("p2", "nfl", "NFL"),
createParticipant("p3", "nfl", "NFL"),
createParticipant("p4", "nba", "NBA"),
createParticipant("p5", "nhl", "NHL"),
];
const teamPicks = [
createPick("team1", "p1", "nfl", "NFL"), // First NFL
createPick("team1", "p2", "nfl", "NFL"), // Flex 1
createPick("team1", "p4", "nba", "NBA"), // First NBA
];
const eligibility = calculateDraftEligibility(
"team1",
teamPicks,
teamPicks,
participants,
sports,
5,
teams
);
expect(eligibility.flexPicksUsed).toBe(1); // One duplicate NFL
expect(eligibility.flexPicksAvailable).toBe(2);
expect(eligibility.picksBySport).toEqual({ nfl: 2, nba: 1 });
});
it("blocks all flex picks when limit reached", () => {
const team1 = createTeam("team1");
const teams = [team1];
const participants = [
...Array.from({ length: 5 }, (_, i) => createParticipant(`nfl${i}`, "nfl", "NFL")),
...Array.from({ length: 5 }, (_, i) => createParticipant(`nba${i}`, "nba", "NBA")),
...Array.from({ length: 5 }, (_, i) => createParticipant(`nhl${i}`, "nhl", "NHL")),
];
const teamPicks = [
createPick("team1", "nfl1", "nfl", "NFL"), // First NFL
createPick("team1", "nfl2", "nfl", "NFL"), // Flex 1
createPick("team1", "nfl3", "nfl", "NFL"), // Flex 2
createPick("team1", "nba1", "nba", "NBA"), // First NBA
];
const eligibility = calculateDraftEligibility(
"team1",
teamPicks,
teamPicks,
participants,
sports,
5, // 5 rounds, 3 sports = 2 flex picks available
teams
);
expect(eligibility.flexPicksUsed).toBe(2);
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");
});
});
describe("Global Sport Availability Constraints", () => {
it("allows first pick from sport when enough participants remain", () => {
const teams = [createTeam("team1"), createTeam("team2"), createTeam("team3")];
const participants = [
createParticipant("p1", "nfl", "NFL"),
createParticipant("p2", "nfl", "NFL"),
createParticipant("p3", "nfl", "NFL"),
createParticipant("p4", "nba", "NBA"),
createParticipant("p5", "nhl", "NHL"),
];
// Team 1 has drafted NFL, teams 2 and 3 haven't
const allPicks = [createPick("team1", "p1", "nfl", "NFL")];
const eligibility = calculateDraftEligibility(
"team2", // Team that hasn't drafted NFL
[], // Team 2's picks (none)
allPicks,
participants,
sports,
5,
teams
);
// 2 NFL participants left, 2 teams still need NFL (team2, team3)
// 2 >= 2, so allowed
expect(eligibility.eligibleSportIds).toContain("nfl");
expect(eligibility.sportAvailability["nfl"].undraftedCount).toBe(2);
expect(eligibility.sportAvailability["nfl"].teamsStillNeeding).toBe(2);
expect(eligibility.sportAvailability["nfl"].canDraftAsFirst).toBe(true);
});
it("blocks flex pick when it would prevent other teams from getting sport", () => {
const teams = [createTeam("team1"), createTeam("team2"), createTeam("team3")];
const participants = [
createParticipant("p1", "nfl", "NFL"),
createParticipant("p2", "nfl", "NFL"),
createParticipant("p3", "nfl", "NFL"),
createParticipant("p4", "nba", "NBA"),
createParticipant("p5", "nhl", "NHL"),
];
// Team 1 has drafted NFL, teams 2 and 3 haven't
const allPicks = [createPick("team1", "p1", "nfl", "NFL")];
const team1Picks = [createPick("team1", "p1", "nfl", "NFL")];
const eligibility = calculateDraftEligibility(
"team1", // Team that already has NFL
team1Picks,
allPicks,
participants,
sports,
5,
teams
);
// 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.sportAvailability["nfl"].canDraftAsFlex).toBe(false);
});
it("allows flex pick when extra participants available", () => {
const teams = [createTeam("team1"), createTeam("team2"), createTeam("team3")];
const participants = [
createParticipant("p1", "nfl", "NFL"),
createParticipant("p2", "nfl", "NFL"),
createParticipant("p3", "nfl", "NFL"),
createParticipant("p4", "nfl", "NFL"), // Extra NFL participant
createParticipant("p5", "nba", "NBA"),
createParticipant("p6", "nhl", "NHL"),
];
const allPicks = [createPick("team1", "p1", "nfl", "NFL")];
const team1Picks = [createPick("team1", "p1", "nfl", "NFL")];
const eligibility = calculateDraftEligibility(
"team1",
team1Picks,
allPicks,
participants,
sports,
5,
teams
);
// 3 NFL participants left, 2 teams still need NFL
// 3 > 2, so flex pick allowed
expect(eligibility.eligibleSportIds).toContain("nfl");
expect(eligibility.sportAvailability["nfl"].canDraftAsFlex).toBe(true);
});
it("handles case where all teams have drafted from a sport", () => {
const teams = [createTeam("team1"), createTeam("team2")];
const participants = [
createParticipant("p1", "nfl", "NFL"),
createParticipant("p2", "nfl", "NFL"),
createParticipant("p3", "nfl", "NFL"),
];
const allPicks = [
createPick("team1", "p1", "nfl", "NFL"),
createPick("team2", "p2", "nfl", "NFL"),
];
const team1Picks = [createPick("team1", "p1", "nfl", "NFL")];
const eligibility = calculateDraftEligibility(
"team1",
team1Picks,
allPicks,
participants,
sports,
5,
teams
);
// 0 teams still need NFL, 1 participant left
// 1 > 0, so flex allowed
expect(eligibility.sportAvailability["nfl"].teamsStillNeeding).toBe(0);
expect(eligibility.sportAvailability["nfl"].canDraftAsFlex).toBe(true);
expect(eligibility.eligibleSportIds).toContain("nfl");
});
});
describe("Combined Constraints", () => {
it("requires both constraints to be satisfied", () => {
const teams = Array.from({ length: 5 }, (_, i) => createTeam(`team${i + 1}`));
const participants = [
...Array.from({ length: 6 }, (_, i) => createParticipant(`nfl${i}`, "nfl", "NFL")),
...Array.from({ length: 6 }, (_, i) => createParticipant(`nba${i}`, "nba", "NBA")),
...Array.from({ length: 6 }, (_, i) => createParticipant(`nhl${i}`, "nhl", "NHL")),
];
// Team 1 has used all flex picks (2) with 3 NFL picks
// 4 teams haven't drafted NFL yet, 3 NFL participants remain
const allPicks = [
createPick("team1", "nfl1", "nfl", "NFL"),
createPick("team1", "nfl2", "nfl", "NFL"),
createPick("team1", "nfl3", "nfl", "NFL"),
createPick("team1", "nba1", "nba", "NBA"),
createPick("team1", "nhl1", "nhl", "NHL"),
];
const team1Picks = allPicks;
const eligibility = calculateDraftEligibility(
"team1",
team1Picks,
allPicks,
participants,
sports,
5, // 5 rounds, 3 sports = 2 flex picks
teams
);
// Flex picks exhausted
expect(eligibility.flexPicksUsed).toBe(2);
expect(eligibility.flexPicksAvailable).toBe(2);
// 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");
});
it("allows pick when both constraints satisfied", () => {
const teams = [createTeam("team1"), createTeam("team2")];
const participants = [
...Array.from({ length: 4 }, (_, i) => createParticipant(`nfl${i}`, "nfl", "NFL")),
...Array.from({ length: 4 }, (_, i) => createParticipant(`nba${i}`, "nba", "NBA")),
...Array.from({ length: 4 }, (_, i) => createParticipant(`nhl${i}`, "nhl", "NHL")),
];
const allPicks = [
createPick("team1", "nfl1", "nfl", "NFL"),
createPick("team1", "nba1", "nba", "NBA"),
];
const team1Picks = allPicks;
const eligibility = calculateDraftEligibility(
"team1",
team1Picks,
allPicks,
participants,
sports,
5,
teams
);
// Has flex picks remaining (0/2 used)
expect(eligibility.flexPicksUsed).toBe(0);
// 3 NFL left, 1 team needs (team2), 3 > 1 so flex allowed
expect(eligibility.sportAvailability["nfl"].undraftedCount).toBe(3);
expect(eligibility.sportAvailability["nfl"].teamsStillNeeding).toBe(1);
expect(eligibility.sportAvailability["nfl"].canDraftAsFlex).toBe(true);
// Both constraints satisfied for NFL
expect(eligibility.eligibleSportIds).toContain("nfl");
});
});
describe("Edge Cases", () => {
it("handles single sport league", () => {
const singleSport = [createSport("nfl", "NFL")];
const teams = [createTeam("team1"), createTeam("team2")];
const participants = Array.from({ length: 10 }, (_, i) =>
createParticipant(`p${i}`, "nfl", "NFL")
);
const eligibility = calculateDraftEligibility(
"team1",
[],
[],
participants,
singleSport,
5,
teams
);
// 5 rounds - 1 sport = 4 flex picks
expect(eligibility.flexPicksAvailable).toBe(4);
expect(eligibility.eligibleSportIds).toContain("nfl");
});
it("handles case with no participants left in sport", () => {
const teams = [createTeam("team1"), createTeam("team2")];
const participants = [
createParticipant("p1", "nfl", "NFL"),
createParticipant("p2", "nba", "NBA"),
createParticipant("p3", "nhl", "NHL"),
];
const allPicks = [
createPick("team1", "p1", "nfl", "NFL"),
];
const eligibility = calculateDraftEligibility(
"team1",
[createPick("team1", "p1", "nfl", "NFL")],
allPicks,
participants,
sports,
5,
teams
);
// Only 1 NFL participant total, 1 drafted = 0 left
expect(eligibility.sportAvailability["nfl"].undraftedCount).toBe(0);
expect(eligibility.sportAvailability["nfl"].canDraftAsFlex).toBe(false);
});
it("blocks first pick when not enough participants for all teams", () => {
const teams = Array.from({ length: 5 }, (_, i) => createTeam(`team${i + 1}`));
const participants = [
createParticipant("p1", "nfl", "NFL"),
createParticipant("p2", "nfl", "NFL"),
createParticipant("p3", "nfl", "NFL"), // Only 3 NFL for 5 teams
createParticipant("p4", "nba", "NBA"),
createParticipant("p5", "nhl", "NHL"),
];
const eligibility = calculateDraftEligibility(
"team1",
[],
[],
participants,
sports,
5,
teams
);
// 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"
);
});
});
describe("getEligibilitySummary", () => {
it("provides readable summary", () => {
const teams = [createTeam("team1")];
const participants = [
createParticipant("p1", "nfl", "NFL"),
createParticipant("p2", "nba", "NBA"),
];
const teamPicks = [
createPick("team1", "p1", "nfl", "NFL"),
createPick("team1", "p2", "nba", "NBA"),
];
const eligibility = calculateDraftEligibility(
"team1",
teamPicks,
teamPicks,
participants,
sports,
5,
teams
);
const summary = getEligibilitySummary(eligibility);
expect(summary).toContain("Flex picks: 0/2");
expect(summary).toContain("NFL: 1");
expect(summary).toContain("NBA: 1");
});
});
describe("Exact Boundary Conditions", () => {
it("allows first pick when undrafted exactly equals teams needing", () => {
const teams = Array.from({ length: 3 }, (_, i) => createTeam(`team${i + 1}`));
const participants = [
createParticipant("nfl1", "nfl", "NFL"),
createParticipant("nfl2", "nfl", "NFL"),
createParticipant("nfl3", "nfl", "NFL"), // Exactly 3 for 3 teams
createParticipant("nba1", "nba", "NBA"),
];
// No picks yet - all 3 teams need NFL
const eligibility = calculateDraftEligibility(
"team1",
[],
[],
participants,
sports,
5,
teams
);
// 3 undrafted, 3 teams need it: 3 >= 3 is TRUE
expect(eligibility.sportAvailability["nfl"].undraftedCount).toBe(3);
expect(eligibility.sportAvailability["nfl"].teamsStillNeeding).toBe(3);
expect(eligibility.sportAvailability["nfl"].canDraftAsFirst).toBe(true);
expect(eligibility.eligibleSportIds).toContain("nfl");
});
it("blocks first pick when undrafted is less than teams needing", () => {
const teams = Array.from({ length: 4 }, (_, i) => createTeam(`team${i + 1}`));
const participants = [
createParticipant("nfl1", "nfl", "NFL"),
createParticipant("nfl2", "nfl", "NFL"),
createParticipant("nfl3", "nfl", "NFL"), // Only 3 for 4 teams
createParticipant("nba1", "nba", "NBA"),
];
const eligibility = calculateDraftEligibility(
"team1",
[],
[],
participants,
sports,
5,
teams
);
// 3 undrafted, 4 teams need it: 3 >= 4 is FALSE
expect(eligibility.sportAvailability["nfl"].undraftedCount).toBe(3);
expect(eligibility.sportAvailability["nfl"].teamsStillNeeding).toBe(4);
expect(eligibility.sportAvailability["nfl"].canDraftAsFirst).toBe(false);
expect(eligibility.eligibleSportIds).not.toContain("nfl");
});
it("allows flex pick when undrafted is exactly one more than teams needing", () => {
const teams = Array.from({ length: 3 }, (_, i) => createTeam(`team${i + 1}`));
const participants = [
...Array.from({ length: 4 }, (_, i) => createParticipant(`nfl${i}`, "nfl", "NFL")), // 4 NFL
createParticipant("nba1", "nba", "NBA"),
];
// Team1 has already picked NFL, teams 2 and 3 haven't
const allPicks = [createPick("team1", "nfl1", "nfl", "NFL")];
const team1Picks = [createPick("team1", "nfl1", "nfl", "NFL")];
const eligibility = calculateDraftEligibility(
"team1",
team1Picks,
allPicks,
participants,
sports,
5,
teams
);
// 3 undrafted, 2 teams need it: 3 > 2 is TRUE (exactly one extra)
expect(eligibility.sportAvailability["nfl"].undraftedCount).toBe(3);
expect(eligibility.sportAvailability["nfl"].teamsStillNeeding).toBe(2);
expect(eligibility.sportAvailability["nfl"].canDraftAsFlex).toBe(true);
expect(eligibility.eligibleSportIds).toContain("nfl");
});
it("blocks flex pick when undrafted equals teams needing", () => {
const teams = Array.from({ length: 3 }, (_, i) => createTeam(`team${i + 1}`));
const participants = [
...Array.from({ length: 3 }, (_, i) => createParticipant(`nfl${i}`, "nfl", "NFL")), // 3 NFL
createParticipant("nba1", "nba", "NBA"),
];
// Team1 has already picked NFL, teams 2 and 3 haven't
const allPicks = [createPick("team1", "nfl1", "nfl", "NFL")];
const team1Picks = [createPick("team1", "nfl1", "nfl", "NFL")];
const eligibility = calculateDraftEligibility(
"team1",
team1Picks,
allPicks,
participants,
sports,
5,
teams
);
// 2 undrafted, 2 teams need it: 2 > 2 is FALSE (exactly equal)
expect(eligibility.sportAvailability["nfl"].undraftedCount).toBe(2);
expect(eligibility.sportAvailability["nfl"].teamsStillNeeding).toBe(2);
expect(eligibility.sportAvailability["nfl"].canDraftAsFlex).toBe(false);
expect(eligibility.eligibleSportIds).not.toContain("nfl");
});
it("blocks all sports when flex picks exactly exhausted", () => {
const teams = [createTeam("team1")];
const participants = [
...Array.from({ length: 5 }, (_, i) => createParticipant(`nfl${i}`, "nfl", "NFL")),
...Array.from({ length: 5 }, (_, i) => createParticipant(`nba${i}`, "nba", "NBA")),
...Array.from({ length: 5 }, (_, i) => createParticipant(`nhl${i}`, "nhl", "NHL")),
];
// 5 rounds, 3 sports = 2 flex picks
// Use exactly 2 flex picks: 3 NFL, 1 NBA (2 NFL duplicates = 2 flexes)
const teamPicks = [
createPick("team1", "nfl1", "nfl", "NFL"),
createPick("team1", "nfl2", "nfl", "NFL"), // Flex 1
createPick("team1", "nfl3", "nfl", "NFL"), // Flex 2
createPick("team1", "nba1", "nba", "NBA"),
];
const eligibility = calculateDraftEligibility(
"team1",
teamPicks,
teamPicks,
participants,
sports,
5,
teams
);
// Exactly at flex limit
expect(eligibility.flexPicksUsed).toBe(2);
expect(eligibility.flexPicksAvailable).toBe(2);
// 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");
});
});
describe("Draft Progression Simulation", () => {
it("shows eligibility changing as teams make sequential picks", () => {
const teams = Array.from({ length: 3 }, (_, i) => createTeam(`team${i + 1}`));
const participants = [
...Array.from({ length: 4 }, (_, i) => createParticipant(`nfl${i}`, "nfl", "NFL")),
...Array.from({ length: 4 }, (_, i) => createParticipant(`nba${i}`, "nba", "NBA")),
...Array.from({ length: 4 }, (_, i) => createParticipant(`nhl${i}`, "nhl", "NHL")),
];
// Start: No picks yet
let allPicks: any[] = [];
// Team1 picks NFL
allPicks = [createPick("team1", "nfl1", "nfl", "NFL")];
let team2Eligibility = calculateDraftEligibility(
"team2",
[],
allPicks,
participants,
sports,
5,
teams
);
// 3 NFL left, 2 teams need it (team2, team3): 3 >= 2, should allow
expect(team2Eligibility.sportAvailability["nfl"].undraftedCount).toBe(3);
expect(team2Eligibility.sportAvailability["nfl"].teamsStillNeeding).toBe(2);
expect(team2Eligibility.eligibleSportIds).toContain("nfl");
// Team2 picks NFL
allPicks = [
createPick("team1", "nfl1", "nfl", "NFL"),
createPick("team2", "nfl2", "nfl", "NFL"),
];
let team3Eligibility = calculateDraftEligibility(
"team3",
[],
allPicks,
participants,
sports,
5,
teams
);
// 2 NFL left, 1 team needs it (team3): 2 >= 1, should allow
expect(team3Eligibility.sportAvailability["nfl"].undraftedCount).toBe(2);
expect(team3Eligibility.sportAvailability["nfl"].teamsStillNeeding).toBe(1);
expect(team3Eligibility.eligibleSportIds).toContain("nfl");
// Team3 picks NFL
allPicks = [
createPick("team1", "nfl1", "nfl", "NFL"),
createPick("team2", "nfl2", "nfl", "NFL"),
createPick("team3", "nfl3", "nfl", "NFL"),
];
// Now team1 wants a second NFL (flex pick)
let team1Eligibility = calculateDraftEligibility(
"team1",
[createPick("team1", "nfl1", "nfl", "NFL")],
allPicks,
participants,
sports,
5,
teams
);
// 1 NFL left, 0 teams need it: 1 > 0, should allow flex
expect(team1Eligibility.sportAvailability["nfl"].undraftedCount).toBe(1);
expect(team1Eligibility.sportAvailability["nfl"].teamsStillNeeding).toBe(0);
expect(team1Eligibility.sportAvailability["nfl"].canDraftAsFlex).toBe(true);
expect(team1Eligibility.eligibleSportIds).toContain("nfl");
});
});
describe("Late-Draft Forced Choices", () => {
it("forces team into specific sport when its the only eligible option", () => {
const teams = [createTeam("team1")];
const participants = [
...Array.from({ length: 3 }, (_, i) => createParticipant(`nfl${i}`, "nfl", "NFL")),
...Array.from({ length: 3 }, (_, i) => createParticipant(`nba${i}`, "nba", "NBA")),
...Array.from({ length: 3 }, (_, i) => createParticipant(`nhl${i}`, "nhl", "NHL")),
];
// 5 rounds, 3 sports, team has picked 4 times (used all flexes)
// Picked: 2 NFL, 2 NBA, need NHL
const teamPicks = [
createPick("team1", "nfl1", "nfl", "NFL"),
createPick("team1", "nfl2", "nfl", "NFL"),
createPick("team1", "nba1", "nba", "NBA"),
createPick("team1", "nba2", "nba", "NBA"),
];
const eligibility = calculateDraftEligibility(
"team1",
teamPicks,
teamPicks,
participants,
sports,
5,
teams
);
// Only NHL is eligible (not yet drafted, and flex picks exhausted)
expect(eligibility.eligibleSportIds).toEqual(new Set(["nhl"]));
expect(eligibility.flexPicksUsed).toBe(2);
expect(eligibility.flexPicksAvailable).toBe(2);
expect(eligibility.picksBySport).toEqual({ nfl: 2, nba: 2 });
});
it("handles complex late-draft scenario with multiple teams", () => {
const teams = Array.from({ length: 4 }, (_, i) => createTeam(`team${i + 1}`));
const participants = [
...Array.from({ length: 5 }, (_, i) => createParticipant(`nfl${i}`, "nfl", "NFL")),
...Array.from({ length: 5 }, (_, i) => createParticipant(`nba${i}`, "nba", "NBA")),
...Array.from({ length: 5 }, (_, i) => createParticipant(`nhl${i}`, "nhl", "NHL")),
];
// Late draft: most participants drafted
const allPicks = [
// Team1: 2 NFL, 2 NBA
createPick("team1", "nfl1", "nfl", "NFL"),
createPick("team1", "nfl2", "nfl", "NFL"),
createPick("team1", "nba1", "nba", "NBA"),
createPick("team1", "nba0", "nba", "NBA"),
// Team2: 2 NBA, 1 NFL
createPick("team2", "nba2", "nba", "NBA"),
createPick("team2", "nba3", "nba", "NBA"),
createPick("team2", "nfl3", "nfl", "NFL"),
// Team3: 2 NHL, 1 NFL, 1 NBA
createPick("team3", "nhl1", "nhl", "NHL"),
createPick("team3", "nhl2", "nhl", "NHL"),
createPick("team3", "nfl4", "nfl", "NFL"),
createPick("team3", "nba4", "nba", "NBA"),
// Team4: 0 picks yet
];
const team4Picks: any[] = [];
const eligibility = calculateDraftEligibility(
"team4",
team4Picks,
allPicks,
participants,
sports,
4, // 4 rounds
teams
);
// Team4 hasn't drafted anything yet
// NFL: 1 left, 1 team needs (team4): 1 >= 1, eligible
// NBA: 0 left, 1 team needs: 0 >= 1 is FALSE, BLOCKED
// NHL: 3 left, 1 team needs: 3 >= 1, eligible
expect(eligibility.sportAvailability["nfl"].undraftedCount).toBe(1);
expect(eligibility.sportAvailability["nfl"].teamsStillNeeding).toBe(1);
expect(eligibility.sportAvailability["nba"].undraftedCount).toBe(0);
expect(eligibility.sportAvailability["nba"].teamsStillNeeding).toBe(1);
// Should be able to draft NFL or NHL, but NOT NBA
expect(eligibility.eligibleSportIds).toContain("nfl");
expect(eligibility.eligibleSportIds).toContain("nhl");
expect(eligibility.eligibleSportIds).not.toContain("nba");
});
});
});

View file

@ -0,0 +1,195 @@
/**
* Draft Eligibility Calculation for Omni Leagues
*
* Implements two key constraints:
* 1. Team Flex Pick Limit: Teams can only use (totalRounds - totalSports) flex picks
* 2. Global Sport Availability: Cannot draft from a sport if it prevents other teams
* from fulfilling their "at least one from each sport" requirement
*/
export interface SportAvailability {
sportId: string;
sportName: string;
totalParticipants: number;
draftedCount: number;
undraftedCount: number;
teamsStillNeeding: number;
canDraftAsFirst: boolean; // undraftedCount >= teamsStillNeeding
canDraftAsFlex: boolean; // undraftedCount > teamsStillNeeding
}
export interface DraftEligibility {
teamId: string;
eligibleSportIds: Set<string>;
ineligibleReasons: Record<string, string>; // sportId -> reason
flexPicksUsed: number;
flexPicksAvailable: number;
picksBySport: Record<string, number>;
sportAvailability: Record<string, SportAvailability>;
}
interface PickData {
teamId: string;
participant: {
id: string;
sport: {
id: string;
name: string;
};
};
}
interface ParticipantData {
id: string;
sport: {
id: string;
name: string;
};
}
interface SportData {
id: string;
name: string;
}
interface TeamData {
id: string;
}
/**
* Calculate which sports a team is eligible to draft from based on:
* - Team's flex pick usage
* - Global sport availability for all teams
*/
export function calculateDraftEligibility(
teamId: string,
teamPicks: PickData[],
allPicks: PickData[],
allParticipants: ParticipantData[],
seasonSports: SportData[],
totalRounds: number,
allTeams: TeamData[]
): DraftEligibility {
// Step 1: Calculate team's flex pick status
const picksBySport: Record<string, number> = {};
for (const pick of teamPicks) {
const sportId = pick.participant.sport.id;
picksBySport[sportId] = (picksBySport[sportId] || 0) + 1;
}
// Calculate flex picks used: sum of (picks - 1) for each sport with multiple picks
let flexPicksUsed = 0;
for (const sportId in picksBySport) {
if (picksBySport[sportId] > 1) {
flexPicksUsed += picksBySport[sportId] - 1;
}
}
const totalSports = seasonSports.length;
const flexPicksAvailable = totalRounds - totalSports;
const hasFlexesRemaining = flexPicksUsed < flexPicksAvailable;
// Step 2: Calculate global sport availability
const sportAvailability: Record<string, SportAvailability> = {};
for (const sport of seasonSports) {
// Count total participants in this sport
const totalParticipants = allParticipants.filter(
p => p.sport.id === sport.id
).length;
// Count drafted participants in this sport
const draftedCount = allPicks.filter(
pick => pick.participant.sport.id === sport.id
).length;
const undraftedCount = totalParticipants - draftedCount;
// Count teams that haven't drafted from this sport yet
const teamsSportPicks: Record<string, number> = {};
for (const pick of allPicks) {
if (pick.participant.sport.id === sport.id) {
teamsSportPicks[pick.teamId] = (teamsSportPicks[pick.teamId] || 0) + 1;
}
}
const teamsStillNeeding = allTeams.filter(
team => !teamsSportPicks[team.id] || teamsSportPicks[team.id] === 0
).length;
sportAvailability[sport.id] = {
sportId: sport.id,
sportName: sport.name,
totalParticipants,
draftedCount,
undraftedCount,
teamsStillNeeding,
canDraftAsFirst: undraftedCount >= teamsStillNeeding,
canDraftAsFlex: undraftedCount > teamsStillNeeding,
};
}
// Step 3: Combine constraints to determine eligibility
const eligibleSportIds = new Set<string>();
const ineligibleReasons: Record<string, string> = {};
for (const sport of seasonSports) {
const sportId = sport.id;
const availability = sportAvailability[sportId];
const currentTeamHasSport = (picksBySport[sportId] || 0) > 0;
let isEligible = false;
let reason = "";
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}`;
}
} else {
// Team has drafted from this sport (would be flex pick)
if (!hasFlexesRemaining) {
reason = `All flex picks used (${flexPicksUsed}/${flexPicksAvailable})`;
} else if (!availability.canDraftAsFlex) {
reason = `Would prevent other teams from getting ${sport.name} (${availability.undraftedCount} left for ${availability.teamsStillNeeding} teams)`;
} else {
isEligible = true;
}
}
if (isEligible) {
eligibleSportIds.add(sportId);
} else {
ineligibleReasons[sportId] = reason;
}
}
return {
teamId,
eligibleSportIds,
ineligibleReasons,
flexPicksUsed,
flexPicksAvailable,
picksBySport,
sportAvailability,
};
}
/**
* Get a human-readable summary of draft eligibility status
*/
export function getEligibilitySummary(eligibility: DraftEligibility): string {
const { flexPicksUsed, flexPicksAvailable, picksBySport, sportAvailability } = eligibility;
const sportBreakdown = Object.entries(picksBySport)
.map(([sportId, count]) => {
const sport = sportAvailability[sportId];
return `${sport?.sportName || sportId}: ${count}`;
})
.join(", ");
return `Flex picks: ${flexPicksUsed}/${flexPicksAvailable} | Picks by sport: ${sportBreakdown || "None"}`;
}

View file

@ -70,3 +70,97 @@ export async function deleteAllDraftPicks(seasonId: string) {
.delete(schema.draftPicks)
.where(eq(schema.draftPicks.seasonId, seasonId));
}
/**
* Get all draft picks for a season with participant and sport information
* Used for draft eligibility calculations
*/
export async function getDraftPicksWithSports(seasonId: string) {
const db = database();
const results = await db
.select({
id: schema.draftPicks.id,
teamId: schema.draftPicks.teamId,
pickNumber: schema.draftPicks.pickNumber,
participantId: schema.participants.id,
participantName: schema.participants.name,
sportId: schema.sports.id,
sportName: schema.sports.name,
})
.from(schema.draftPicks)
.innerJoin(
schema.participants,
eq(schema.draftPicks.participantId, schema.participants.id)
)
.innerJoin(
schema.sportsSeasons,
eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id)
)
.innerJoin(
schema.sports,
eq(schema.sportsSeasons.sportId, schema.sports.id)
)
.where(eq(schema.draftPicks.seasonId, seasonId))
.orderBy(schema.draftPicks.pickNumber);
// Transform to expected format
return results.map((r) => ({
teamId: r.teamId,
participant: {
id: r.participantId,
sport: {
id: r.sportId,
name: r.sportName,
},
},
}));
}
/**
* Get team's draft picks with participant and sport information
*/
export async function getTeamDraftPicksWithSports(teamId: string, seasonId: string) {
const db = database();
const results = await db
.select({
id: schema.draftPicks.id,
teamId: schema.draftPicks.teamId,
pickNumber: schema.draftPicks.pickNumber,
participantId: schema.participants.id,
participantName: schema.participants.name,
sportId: schema.sports.id,
sportName: schema.sports.name,
})
.from(schema.draftPicks)
.innerJoin(
schema.participants,
eq(schema.draftPicks.participantId, schema.participants.id)
)
.innerJoin(
schema.sportsSeasons,
eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id)
)
.innerJoin(
schema.sports,
eq(schema.sportsSeasons.sportId, schema.sports.id)
)
.where(
and(
eq(schema.draftPicks.teamId, teamId),
eq(schema.draftPicks.seasonId, seasonId)
)
)
.orderBy(schema.draftPicks.pickNumber);
// Transform to expected format
return results.map((r) => ({
teamId: r.teamId,
participant: {
id: r.participantId,
sport: {
id: r.sportId,
name: r.sportName,
},
},
}));
}

View file

@ -1,45 +1,89 @@
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, notInArray, desc } from "drizzle-orm";
import { eq, and, notInArray, desc, inArray } from "drizzle-orm";
import { getTeamQueue } from "./draft-queue";
import { isParticipantDrafted } from "./draft-pick";
import { isParticipantDrafted, getDraftPicksWithSports, getTeamDraftPicksWithSports } from "./draft-pick";
import { getParticipantsForSeasonWithSports } from "./participant";
import { getSeasonSportsSimple } from "./season-sport";
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
/**
* Auto-pick for a team when their timer runs out
* 1. Check queue - pick first item if available
* 2. If queue empty, pick highest EV participant not drafted
* 1. Check queue - pick first eligible item if available
* 2. If queue empty, pick highest EV participant not drafted from eligible sports
*
* Updated to respect Omni league draft eligibility rules
*/
export async function autoPickForTeam(seasonId: string, teamId: string) {
// Check queue first
export async function autoPickForTeam(
seasonId: string,
teamId: string,
draftRounds: number,
allTeamIds: string[]
) {
const db = database();
// Calculate eligibility for this team
const allPicks = await getDraftPicksWithSports(seasonId);
const teamPicks = await getTeamDraftPicksWithSports(teamId, seasonId);
const allParticipants = await getParticipantsForSeasonWithSports(seasonId);
const seasonSports = await getSeasonSportsSimple(seasonId);
const allTeams = allTeamIds.map((id) => ({ id }));
const eligibility = calculateDraftEligibility(
teamId,
teamPicks,
allPicks,
allParticipants,
seasonSports,
draftRounds,
allTeams
);
// Check queue first - filter by eligible sports
const queue = await getTeamQueue(teamId);
if (queue.length > 0) {
// Pick first item from queue
const firstInQueue = queue[0];
// Get participant details for queue items to check sport eligibility
const queueParticipantIds = queue.map((item) => item.participantId);
const queueParticipants = await db.query.participants.findMany({
where: inArray(schema.participants.id, queueParticipantIds),
with: {
sportsSeason: {
with: {
sport: true,
},
},
},
});
// Verify participant is not already drafted
const isDrafted = await isParticipantDrafted(seasonId, firstInQueue.participantId);
if (!isDrafted) {
return firstInQueue.participantId;
}
// Try queue items in order, checking both drafted status and eligibility
for (const item of queue) {
const participant = queueParticipants.find((p) => p.id === item.participantId);
if (!participant) continue;
// If first item was drafted, try next items in queue
for (const item of queue.slice(1)) {
const sportId = participant.sportsSeason.sport.id;
const isEligible = eligibility.eligibleSportIds.has(sportId);
const isDrafted = await isParticipantDrafted(seasonId, item.participantId);
if (!isDrafted) {
if (!isDrafted && isEligible) {
return item.participantId;
}
}
}
// Queue is empty or all queued players drafted - pick highest EV available
return await getTopAvailableParticipant(seasonId);
// Queue is empty or all queued players drafted/ineligible
// Pick highest EV available from eligible sports
return await getTopAvailableParticipant(seasonId, eligibility.eligibleSportIds);
}
/**
* Get the highest EV participant that hasn't been drafted yet
* Updated to filter by eligible sports
*/
export async function getTopAvailableParticipant(seasonId: string) {
export async function getTopAvailableParticipant(
seasonId: string,
eligibleSportIds?: Set<string>
) {
const db = database();
// Get all drafted participant IDs
@ -50,13 +94,41 @@ export async function getTopAvailableParticipant(seasonId: string) {
const draftedIds = draftedPicks.map((p: { participantId: string }) => p.participantId);
// Get all participants from season sports
const seasonSportsData = await db
.select({ sportsSeasonId: schema.seasonSports.sportsSeasonId })
.from(schema.seasonSports)
.where(eq(schema.seasonSports.seasonId, seasonId));
// Get all participants from season sports, filtered by eligible sports if provided
let seasonSportsData;
if (eligibleSportIds && eligibleSportIds.size > 0) {
// Filter to only eligible sports
seasonSportsData = await db
.select({
sportsSeasonId: schema.seasonSports.sportsSeasonId,
sportId: schema.sports.id,
})
.from(schema.seasonSports)
.innerJoin(
schema.sportsSeasons,
eq(schema.seasonSports.sportsSeasonId, schema.sportsSeasons.id)
)
.innerJoin(
schema.sports,
eq(schema.sportsSeasons.sportId, schema.sports.id)
)
.where(eq(schema.seasonSports.seasonId, seasonId));
const sportsSeasonIds = seasonSportsData.map((s: { sportsSeasonId: string }) => s.sportsSeasonId);
// Filter to only eligible sports
seasonSportsData = seasonSportsData.filter((s: { sportId: string }) =>
eligibleSportIds.has(s.sportId)
);
} else {
// No filtering - get all sports
seasonSportsData = await db
.select({ sportsSeasonId: schema.seasonSports.sportsSeasonId })
.from(schema.seasonSports)
.where(eq(schema.seasonSports.seasonId, seasonId));
}
const sportsSeasonIds = seasonSportsData.map(
(s: { sportsSeasonId: string }) => s.sportsSeasonId
);
if (sportsSeasonIds.length === 0) {
return null;

View file

@ -1,4 +1,4 @@
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
@ -100,3 +100,49 @@ export async function copyParticipantsFromSeason(
}))
);
}
/**
* Get all participants for a season with sport information
* Used for draft eligibility calculations
*/
export async function getParticipantsForSeasonWithSports(seasonId: string) {
const db = database();
// First get all sports seasons for this season
const seasonSports = await db.query.seasonSports.findMany({
where: eq(schema.seasonSports.seasonId, seasonId),
});
if (seasonSports.length === 0) {
return [];
}
const sportsSeasonIds = seasonSports.map((ss) => ss.sportsSeasonId);
// Get all participants for these sports seasons with sport info
const participants = await db
.select({
id: schema.participants.id,
name: schema.participants.name,
sport: {
id: schema.sports.id,
name: schema.sports.name,
},
})
.from(schema.participants)
.innerJoin(
schema.sportsSeasons,
eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id)
)
.innerJoin(
schema.sports,
eq(schema.sportsSeasons.sportId, schema.sports.id)
)
.where(
sportsSeasonIds.length === 1
? eq(schema.participants.sportsSeasonId, sportsSeasonIds[0])
: inArray(schema.participants.sportsSeasonId, sportsSeasonIds)
);
return participants;
}

View file

@ -106,3 +106,26 @@ export async function applySportsFromTemplate(
}))
);
}
/**
* Get sports for a season in a simple format for eligibility calculations
*/
export async function getSeasonSportsSimple(seasonId: string) {
const db = database();
const seasonSports = await db.query.seasonSports.findMany({
where: eq(schema.seasonSports.seasonId, seasonId),
with: {
sportsSeason: {
with: {
sport: true,
},
},
},
});
return seasonSports.map((ss) => ({
id: ss.sportsSeason.sport.id,
name: ss.sportsSeason.sport.name,
}));
}

View file

@ -1,7 +1,8 @@
import { getAuth } from "@clerk/react-router/server";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, notInArray, desc, asc, inArray } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import { autoPickForTeam } from "~/models/draft-utils";
export async function action(args: any) {
const { request } = args;
@ -40,91 +41,7 @@ export async function action(args: any) {
return Response.json({ error: "Only commissioner can force autopick" }, { status: 403 });
}
// Get team's queue
const teamQueue = await db.query.draftQueue.findMany({
where: eq(schema.draftQueue.teamId, teamId),
orderBy: schema.draftQueue.queuePosition,
with: {
participant: {
with: {
sportsSeason: {
with: {
sport: true,
},
},
},
},
},
});
// Get already drafted participant IDs
const draftPicks = await db.query.draftPicks.findMany({
where: eq(schema.draftPicks.seasonId, seasonId),
});
const draftedParticipantIds = draftPicks.map((p) => p.participantId);
let participantToPick = null;
// 1. Try to pick from queue (first non-drafted participant)
for (const queueItem of teamQueue) {
if (!draftedParticipantIds.includes(queueItem.participantId)) {
participantToPick = queueItem.participant;
break;
}
}
// 2. If no valid queue item, pick highest EV available participant
if (!participantToPick) {
// Get sports seasons for this season
const seasonSports = await db.query.seasonSports.findMany({
where: eq(schema.seasonSports.seasonId, seasonId),
});
const sportsSeasonIds = seasonSports.map((ss) => ss.sportsSeasonId);
if (sportsSeasonIds.length > 0) {
const availableParticipants = await db
.select({
id: schema.participants.id,
name: schema.participants.name,
expectedValue: schema.participants.expectedValue,
sportsSeasonId: schema.participants.sportsSeasonId,
})
.from(schema.participants)
.where(
and(
sportsSeasonIds.length === 1
? eq(schema.participants.sportsSeasonId, sportsSeasonIds[0])
: inArray(schema.participants.sportsSeasonId, sportsSeasonIds),
draftedParticipantIds.length > 0
? notInArray(schema.participants.id, draftedParticipantIds)
: undefined
)
)
.orderBy(desc(schema.participants.expectedValue), asc(schema.participants.name))
.limit(1);
if (availableParticipants.length > 0) {
// Get full participant with sport info
participantToPick = await db.query.participants.findFirst({
where: eq(schema.participants.id, availableParticipants[0].id),
with: {
sportsSeason: {
with: {
sport: true,
},
},
},
});
}
}
}
if (!participantToPick) {
return Response.json({ error: "No available participants to pick" }, { status: 400 });
}
// Calculate round and pickInRound
// Get draft slots to pass team IDs
const draftSlots = await db.query.draftSlots.findMany({
where: eq(schema.draftSlots.seasonId, seasonId),
orderBy: schema.draftSlots.draftOrder,
@ -133,6 +50,37 @@ export async function action(args: any) {
},
});
const allTeamIds = draftSlots.map((slot) => slot.teamId);
// Use autoPickForTeam which now respects eligibility
const participantId = await autoPickForTeam(
seasonId,
teamId,
season.draftRounds,
allTeamIds
);
if (!participantId) {
return Response.json({ error: "No eligible participants available to pick" }, { status: 400 });
}
// Get participant details
const participantToPick = await db.query.participants.findFirst({
where: eq(schema.participants.id, participantId),
with: {
sportsSeason: {
with: {
sport: true,
},
},
},
});
if (!participantToPick) {
return Response.json({ error: "Participant not found" }, { status: 404 });
}
// Calculate round and pickInRound
const totalTeams = draftSlots.length;
const currentRound = Math.ceil(pickNumber / totalTeams);
const isEvenRound = currentRound % 2 === 0;

View file

@ -2,6 +2,10 @@ import { getAuth } from "@clerk/react-router/server";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and } from "drizzle-orm";
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
import { getParticipantsForSeasonWithSports } from "~/models/participant";
import { getSeasonSportsSimple } from "~/models/season-sport";
export async function action(args: any) {
const { request } = args;
@ -86,6 +90,31 @@ export async function action(args: any) {
pickInRound = totalTeams - pickInRound + 1;
}
// ELIGIBILITY VALIDATION: Check if team can draft from this sport
const allPicks = await getDraftPicksWithSports(seasonId);
const teamPicks = await getTeamDraftPicksWithSports(teamId, seasonId);
const allParticipants = await getParticipantsForSeasonWithSports(seasonId);
const seasonSports = await getSeasonSportsSimple(seasonId);
// Get all teams for the season
const allTeams = draftSlots.map((slot) => ({ id: slot.teamId }));
const eligibility = calculateDraftEligibility(
teamId,
teamPicks,
allPicks,
allParticipants,
seasonSports,
season.draftRounds,
allTeams
);
const sportId = participant.sportsSeason.sport.id;
if (!eligibility.eligibleSportIds.has(sportId)) {
const reason = eligibility.ineligibleReasons[sportId] || "Cannot draft from this sport";
return Response.json({ error: reason }, { status: 400 });
}
// Create the draft pick
const [draftPick] = await db
.insert(schema.draftPicks)

View file

@ -2,6 +2,10 @@ import { getAuth } from "@clerk/react-router/server";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and } from "drizzle-orm";
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
import { getParticipantsForSeasonWithSports } from "~/models/participant";
import { getSeasonSportsSimple } from "~/models/season-sport";
export async function action(args: any) {
const { request } = args;
@ -97,6 +101,31 @@ export async function action(args: any) {
return Response.json({ error: "Participant not found" }, { status: 404 });
}
// ELIGIBILITY VALIDATION: Check if team can draft from this sport
const allPicks = await getDraftPicksWithSports(seasonId);
const teamPicks = await getTeamDraftPicksWithSports(currentDraftSlot.teamId, seasonId);
const allParticipants = await getParticipantsForSeasonWithSports(seasonId);
const seasonSports = await getSeasonSportsSimple(seasonId);
// Get all teams for the season
const allTeams = draftSlots.map((slot) => ({ id: slot.teamId }));
const eligibility = calculateDraftEligibility(
currentDraftSlot.teamId,
teamPicks,
allPicks,
allParticipants,
seasonSports,
season.draftRounds,
allTeams
);
const sportId = participant.sportsSeason.sport.id;
if (!eligibility.eligibleSportIds.has(sportId)) {
const reason = eligibility.ineligibleReasons[sportId] || "Cannot draft from this sport";
return Response.json({ error: reason }, { status: 400 });
}
// Create the draft pick
const [draftPick] = await db
.insert(schema.draftPicks)

View file

@ -3,7 +3,7 @@ import { eq, and, asc, desc, inArray } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { useDraftSocket } from "~/hooks/useDraftSocket";
import { useEffect, useState } from "react";
import { useEffect, useState, useMemo } from "react";
import { Card } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
@ -16,6 +16,7 @@ import {
ContextMenuTrigger,
} from "~/components/ui/context-menu";
import { AutodraftSettings } from "~/components/AutodraftSettings";
import { calculateDraftEligibility, getEligibilitySummary } from "~/lib/draft-eligibility";
export async function loader(args: any) {
const { params } = args;
@ -247,6 +248,101 @@ export default function DraftRoom() {
teamId: string;
} | null>(null);
// Calculate draft eligibility for current user's team
const eligibility = useMemo(() => {
if (!userTeam) return null;
// Transform picks data to match eligibility function signature
const transformedPicks = picks.map((p: any) => ({
teamId: p.team.id,
participant: {
id: p.participant.id,
sport: {
id: p.sport.id,
name: p.sport.name,
},
},
}));
const userTeamPicks = transformedPicks.filter(
(p: any) => p.teamId === userTeam.id
);
// Transform participants to match signature
const transformedParticipants = availableParticipants.map((p: any) => ({
id: p.id,
sport: {
id: p.sport.id,
name: p.sport.name,
},
}));
// Get unique sports from availableParticipants
const sportsMap = new Map();
availableParticipants.forEach((p: any) => {
if (!sportsMap.has(p.sport.id)) {
sportsMap.set(p.sport.id, { id: p.sport.id, name: p.sport.name });
}
});
const seasonSportsData = Array.from(sportsMap.values());
return calculateDraftEligibility(
userTeam.id,
userTeamPicks,
transformedPicks,
transformedParticipants,
seasonSportsData,
season.draftRounds,
season.teams
);
}, [userTeam, picks, availableParticipants, season]);
// Calculate eligibility for force manual pick dialog (selected team)
const forcePickEligibility = useMemo(() => {
if (!selectedPickSlot) return null;
const transformedPicks = picks.map((p: any) => ({
teamId: p.team.id,
participant: {
id: p.participant.id,
sport: {
id: p.sport.id,
name: p.sport.name,
},
},
}));
const selectedTeamPicks = transformedPicks.filter(
(p: any) => p.teamId === selectedPickSlot.teamId
);
const transformedParticipants = availableParticipants.map((p: any) => ({
id: p.id,
sport: {
id: p.sport.id,
name: p.sport.name,
},
}));
const sportsMap = new Map();
availableParticipants.forEach((p: any) => {
if (!sportsMap.has(p.sport.id)) {
sportsMap.set(p.sport.id, { id: p.sport.id, name: p.sport.name });
}
});
const seasonSportsData = Array.from(sportsMap.values());
return calculateDraftEligibility(
selectedPickSlot.teamId,
selectedTeamPicks,
transformedPicks,
transformedParticipants,
seasonSportsData,
season.draftRounds,
season.teams
);
}, [selectedPickSlot, picks, availableParticipants, season]);
// Listen for new picks from other users
useEffect(() => {
const handlePickMade = (data: any) => {
@ -837,6 +933,50 @@ export default function DraftRoom() {
</Button>
)}
</div>
{/* Eligibility Status Banner */}
{eligibility && (
<div className="mb-4 p-3 bg-muted/50 rounded-lg border">
<div className="text-sm font-semibold mb-2">Draft Status</div>
<div className="text-xs space-y-1">
<div className="flex justify-between">
<span>Flex Picks:</span>
<span className={eligibility.flexPicksUsed >= eligibility.flexPicksAvailable ? "text-red-600 dark:text-red-400 font-semibold" : "text-green-600 dark:text-green-400"}>
{eligibility.flexPicksUsed} / {eligibility.flexPicksAvailable}
</span>
</div>
{Object.entries(eligibility.picksBySport).length > 0 && (
<>
<div className="font-semibold mt-2 mb-1">Picks by Sport:</div>
{Object.entries(eligibility.picksBySport).map(([sportId, count]) => {
const sportInfo = eligibility.sportAvailability[sportId];
return (
<div key={sportId} className="flex justify-between items-center pl-2">
<span>{sportInfo?.sportName || sportId}:</span>
<span className="font-mono">{count}</span>
</div>
);
})}
</>
)}
{Object.keys(eligibility.ineligibleReasons).length > 0 && (
<>
<div className="font-semibold mt-2 mb-1 text-red-600 dark:text-red-400">
Restrictions:
</div>
{Object.entries(eligibility.ineligibleReasons).slice(0, 2).map(([sportId, reason]) => {
const sportInfo = eligibility.sportAvailability[sportId];
return (
<div key={sportId} className="text-xs text-red-600 dark:text-red-400 pl-2">
{sportInfo?.sportName}: {reason}
</div>
);
})}
</>
)}
</div>
</div>
)}
{queue.length === 0 ? (
<p className="text-muted-foreground text-sm text-center py-8">
Click participants to add to your queue
@ -1010,18 +1150,27 @@ export default function DraftRoom() {
(item: any) => item.participantId === participant.id
);
// Check eligibility
const isEligible = eligibility
? eligibility.eligibleSportIds.has(participant.sport.id)
: true;
const ineligibleReason = eligibility?.ineligibleReasons[participant.sport.id];
return (
<tr
key={participant.id}
className={`border-t transition-colors ${
isDrafted
? "bg-muted/50 opacity-60"
: "hover:bg-muted/50"
: !isEligible
? "bg-red-50/30 dark:bg-red-950/20 opacity-75"
: "hover:bg-muted/50"
}`}
title={!isEligible && !isDrafted ? ineligibleReason : undefined}
>
<td className="p-3">
<div className="flex items-center gap-2">
<span className="font-medium">
<span className={`font-medium ${!isEligible && !isDrafted ? "text-muted-foreground" : ""}`}>
{participant.name}
</span>
{isDrafted && (
@ -1032,7 +1181,16 @@ export default function DraftRoom() {
Drafted
</Badge>
)}
{isInQueue && !isDrafted && (
{!isDrafted && !isEligible && (
<Badge
variant="destructive"
className="text-xs"
title={ineligibleReason}
>
Ineligible
</Badge>
)}
{isInQueue && !isDrafted && isEligible && (
<Badge
variant="default"
className="text-xs"
@ -1060,6 +1218,8 @@ export default function DraftRoom() {
onClick={() =>
handleMakePick(participant.id)
}
disabled={!isEligible}
title={!isEligible ? ineligibleReason : undefined}
>
Pick
</Button>
@ -1071,7 +1231,8 @@ export default function DraftRoom() {
onClick={() =>
handleAddToQueue(participant.id)
}
title="Add to queue"
title={!isEligible ? ineligibleReason : "Add to queue"}
disabled={!isEligible}
>
<span className="text-lg">+</span>
</Button>
@ -1107,6 +1268,8 @@ export default function DraftRoom() {
onClick={() =>
handleAddToQueue(participant.id)
}
disabled={!isEligible}
title={!isEligible ? ineligibleReason : undefined}
>
Add to Queue
</Button>
@ -1201,32 +1364,60 @@ export default function DraftRoom() {
<tbody>
{filteredParticipants
.filter((p: any) => !draftedParticipantIds.has(p.id))
.map((participant: any) => (
<tr
key={participant.id}
className="border-t hover:bg-muted/50"
>
<td className="p-3 font-medium">
{participant.name}
</td>
<td className="p-3 text-muted-foreground">
{participant.sport.name}
</td>
<td className="p-3 text-right font-mono font-semibold">
{participant.expectedValue}
</td>
<td className="p-3 text-right">
<Button
size="sm"
onClick={() =>
handleForceManualPick(participant.id)
}
>
Draft
</Button>
</td>
</tr>
))}
.map((participant: any) => {
// Check eligibility for force pick team
const isEligible = forcePickEligibility
? forcePickEligibility.eligibleSportIds.has(participant.sport.id)
: true;
const ineligibleReason = forcePickEligibility?.ineligibleReasons[participant.sport.id];
return (
<tr
key={participant.id}
className={`border-t ${
isEligible
? "hover:bg-muted/50"
: "bg-red-50/30 dark:bg-red-950/20 opacity-60"
}`}
title={!isEligible ? ineligibleReason : undefined}
>
<td className="p-3">
<div className="flex items-center gap-2">
<span className={`font-medium ${!isEligible ? "text-muted-foreground" : ""}`}>
{participant.name}
</span>
{!isEligible && (
<Badge
variant="destructive"
className="text-xs"
title={ineligibleReason}
>
Ineligible
</Badge>
)}
</div>
</td>
<td className="p-3 text-muted-foreground">
{participant.sport.name}
</td>
<td className="p-3 text-right font-mono font-semibold">
{participant.expectedValue}
</td>
<td className="p-3 text-right">
<Button
size="sm"
onClick={() =>
handleForceManualPick(participant.id)
}
disabled={!isEligible}
title={!isEligible ? ineligibleReason : undefined}
>
Draft
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>

View file

@ -0,0 +1,336 @@
# Omni League Draft Rules - Implementation Summary
## Overview
Successfully implemented Omni league draft eligibility rules that enforce:
1. **Team Flex Pick Limits**: Teams can only use (totalRounds - totalSports) flex picks
2. **Global Sport Availability**: Prevents teams from drafting in a way that blocks other teams from meeting the "at least one from each sport" requirement
## Implementation Status: ✅ COMPLETE
All phases have been completed and type-checked successfully.
---
## Phase 1: Core Logic ✅
### Files Created
- `app/lib/draft-eligibility.ts` - Core eligibility calculation with full TypeScript types
- `app/lib/__tests__/draft-eligibility.test.ts` - Comprehensive test suite (13 tests, all passing)
### Key Functions
- `calculateDraftEligibility()` - Main eligibility calculator
- Inputs: team picks, all picks, all participants, sports, rounds, teams
- Returns: eligible sports, ineligible reasons, flex pick status, sport availability
- `getEligibilitySummary()` - Human-readable status summary
### Test Coverage
- Team flex pick constraints (3 tests)
- Global sport availability constraints (4 tests)
- Combined constraints (2 tests)
- Edge cases (3 tests)
- Summary generation (1 test)
All tests pass ✅
---
## Phase 2: Backend Validation ✅
### Model Layer Enhancements
**`app/models/draft-pick.ts`**
- Added `getDraftPicksWithSports()` - Get all picks with sport data
- Added `getTeamDraftPicksWithSports()` - Get team-specific picks with sport data
**`app/models/participant.ts`**
- Added `getParticipantsForSeasonWithSports()` - Get all participants with sport association
**`app/models/season-sport.ts`**
- Added `getSeasonSportsSimple()` - Get simple sport list for calculations
**`app/models/draft-utils.ts`**
- Updated `autoPickForTeam()` - Now respects eligibility constraints
- Filters queue by eligible sports
- Falls back to eligible sports when queue is empty
- Signature changed to require `draftRounds` and `allTeamIds`
- Updated `getTopAvailableParticipant()` - Now accepts optional `eligibleSportIds` filter
### API Endpoints Updated
**`app/routes/api/draft.make-pick.ts`** ✅
- Added eligibility validation before creating pick
- Returns specific error reason if sport is ineligible
- Validates both team flex limits and global availability
**`app/routes/api/draft.force-manual-pick.ts`** ✅
- Added same eligibility validation
- Commissioner picks must also respect rules
**`app/routes/api/draft.force-autopick.ts`** ✅
- Refactored to use updated `autoPickForTeam()` function
- Automatically respects eligibility via autodraft logic
### Validation Flow
1. Load all necessary data (picks, participants, sports, teams)
2. Calculate eligibility using `calculateDraftEligibility()`
3. Check if participant's sport is in `eligibleSportIds`
4. Return 400 error with specific reason if ineligible
5. Proceed with pick if eligible
---
## Phase 3: Frontend UI ✅
### Draft Room Updates (`app/routes/leagues/$leagueId.draft.$seasonId.tsx`)
**New Imports**
- `useMemo` from React
- `calculateDraftEligibility` and `getEligibilitySummary` from eligibility lib
**Eligibility Calculation**
- Added `useMemo` hook to calculate user team's eligibility
- Recalculates when picks, participants, or team changes
- Transforms loader data to match eligibility function signature
**Force Pick Eligibility**
- Separate `useMemo` for force manual pick dialog
- Calculates eligibility for the selected team (not current user)
### Visual Indicators
**Participant Table**
1. **Row Styling**
- Ineligible participants: red background (`bg-red-50/30 dark:bg-red-950/20`)
- Drafted participants: muted with opacity
- Eligible participants: normal hover state
2. **Badges**
- "Ineligible" badge (destructive variant) for blocked sports
- Shows ineligible reason on hover
- "Drafted" badge for already picked participants
- "Queued" badge only shown if participant is also eligible
3. **Buttons**
- "Pick" button disabled when sport is ineligible
- "Add to Queue" button disabled for ineligible sports
- Tooltips explain why buttons are disabled
### Status Banner (in "My Queue" section)
**Displays:**
- Flex picks used/available with color coding:
- Green when flexes remain
- Red when exhausted
- Picks by sport breakdown (e.g., "NFL: 2, NBA: 1")
- Active restrictions with reasons (first 2 shown)
- Example: "NFL: Would prevent other teams from getting NFL (2 left for 3 teams)"
**Styling:**
- Bordered card with muted background
- Clear hierarchy with sections
- Color-coded warnings
### Force Manual Pick Dialog
**Enhancements:**
- Uses `forcePickEligibility` calculation for target team
- Filters out ineligible participants:
- Red background for ineligible rows
- "Ineligible" badge with reason
- Disabled "Draft" button
- Shows participant sport and EV
- Maintains existing search and filter functionality
---
## Technical Details
### Eligibility Algorithm
**Team Flex Pick Check:**
```typescript
flexPicksUsed = sum of (picks in sport - 1) for each sport with >1 pick
flexPicksAvailable = totalRounds - totalSports
hasFlexesRemaining = flexPicksUsed < flexPicksAvailable
```
**Global Sport Availability:**
```typescript
For each sport:
undraftedCount = total participants - drafted participants
teamsStillNeeding = teams with 0 picks in this sport
canDraftAsFirst = undraftedCount >= teamsStillNeeding
canDraftAsFlex = undraftedCount > teamsStillNeeding
```
**Combined Logic:**
- If team hasn't drafted from sport: allowed if `canDraftAsFirst`
- If team has drafted from sport (flex): allowed if `hasFlexesRemaining AND canDraftAsFlex`
### Data Flow
1. **Loader** loads all necessary data (picks, participants, sports, teams)
2. **Component** calculates eligibility in `useMemo`
3. **UI** renders with eligibility state
4. **User action** triggers API call
5. **API** validates eligibility server-side
6. **Socket** broadcasts pick to all clients
7. **Clients** update state and recalculate eligibility
### Performance
- Eligibility calculation: O(teams × rounds) ≈ O(200) for typical draft
- Memoized in React - only recalculates when picks change
- No database changes needed
- Minimal API overhead (one calculation per pick attempt)
---
## Testing
### Unit Tests ✅
- **21 comprehensive tests** covering all eligibility scenarios:
- Team flex pick constraints (3 tests)
- Global sport availability constraints (4 tests)
- Combined constraints (2 tests)
- Edge cases (3 tests)
- Summary generation (1 test)
- **Exact boundary conditions (6 tests)** - Testing critical thresholds
- **Draft progression simulation (1 test)** - Multi-pick scenarios
- **Late-draft forced choices (2 tests)** - Constraint-driven selections
- Run with: `npm test -- app/lib/__tests__/draft-eligibility.test.ts`
- **All 21 tests passing**
### Test Coverage Details
The test suite thoroughly validates:
- ✅ Team cannot exceed flex pick limit
- ✅ Global sport availability protects future teams
- ✅ Exact boundary conditions (e.g., undrafted === teamsNeeding)
- ✅ Eligibility changes as draft progresses
- ✅ Late-draft scenarios where only one sport is eligible
- ✅ Complex multi-team scenarios
### Type Safety ✅
- Full TypeScript coverage
- Run with: `npm run typecheck`
- No errors
### Integration Testing Notes
- The core eligibility algorithm is thoroughly unit tested (21 tests)
- Backend API endpoints include inline eligibility validation
- Autodraft logic (`draft-utils.ts`) uses the tested eligibility function
- Manual/E2E testing recommended for full integration validation
### Manual Testing Needed
- [ ] E2E test for draft flow with eligibility restrictions
- [ ] Test autodraft respects eligibility in live draft
- [ ] Test force picks respect eligibility
- [ ] Test UI shows correct eligibility status and messaging
- [ ] Test with various team/sport/round configurations
---
## Files Modified
### Created
1. `app/lib/draft-eligibility.ts`
2. `app/lib/__tests__/draft-eligibility.test.ts`
### Modified
1. `app/models/draft-pick.ts`
2. `app/models/participant.ts`
3. `app/models/season-sport.ts`
4. `app/models/draft-utils.ts`
5. `app/routes/api/draft.make-pick.ts`
6. `app/routes/api/draft.force-manual-pick.ts`
7. `app/routes/api/draft.force-autopick.ts`
8. `app/routes/leagues/$leagueId.draft.$seasonId.tsx`
---
## Usage Examples
### Backend Validation
```typescript
// In API route
const eligibility = calculateDraftEligibility(
teamId,
teamPicks,
allPicks,
allParticipants,
seasonSports,
season.draftRounds,
allTeams
);
if (!eligibility.eligibleSportIds.has(participant.sport.id)) {
return Response.json({
error: eligibility.ineligibleReasons[participant.sport.id]
}, { status: 400 });
}
```
### Frontend UI
```typescript
// In component
const eligibility = useMemo(() => {
if (!userTeam) return null;
return calculateDraftEligibility(/*...*/);
}, [userTeam, picks, availableParticipants, season]);
// In render
const isEligible = eligibility?.eligibleSportIds.has(participant.sport.id);
const ineligibleReason = eligibility?.ineligibleReasons[participant.sport.id];
<Button disabled={!isEligible} title={ineligibleReason}>
Pick
</Button>
```
---
## Edge Cases Handled
1. **Single sport league**: Flex picks still apply (rounds - 1)
2. **No eligible sports**: API returns clear error, autodraft falls back
3. **All teams drafted from sport**: Global constraint becomes `undrafted > 0`
4. **Empty participant pool**: Returns null, handled gracefully
5. **Mid-draft state changes**: Eligibility recalculates on every pick
---
## Future Enhancements
As documented in the plan:
1. Pre-draft validation (warn commissioners of impossible configs)
2. Queue validation warnings
3. Scarcity indicators on participant list
4. Draft strategy recommendations
5. Configurable rules per league
6. Position-based restrictions within sports
7. Commissioner undo/correction tools
8. Analytics on constraint triggers
---
## Deployment Checklist
- [x] All code written and tested
- [x] TypeScript compilation successful
- [x] Unit tests passing
- [ ] Manual testing complete
- [ ] E2E tests written
- [ ] Database migrations (none needed)
- [ ] Documentation updated
- [ ] Code review completed
---
## Notes
- No database schema changes required
- All validation happens in application layer
- Backward compatible (will work with existing drafts)
- Performance impact minimal (simple calculations)
- User experience significantly improved with clear feedback

View file

@ -0,0 +1,424 @@
# Omni League Draft Rules Implementation Plan
## Overview
Implement draft restrictions for Omni leagues where teams must draft at least one participant from each sport, with limited "flex" picks for duplicates.
## Business Rules
### Core Rule 1: Team Flex Pick Limit
- **Must draft at least one from each sport**
- **Flex picks** = Total rounds - Number of sports
- Once all flex picks are used, can only draft from sports not yet drafted
### Core Rule 2: Global Sport Availability (NEW)
- **Cannot draft from a sport if it would prevent other teams from fulfilling their requirement**
- For each sport, track:
- Undrafted participants remaining in that sport
- Teams that haven't drafted from that sport yet
- A team can draft from a sport only if:
- **First pick in sport**: `undraftedInSport >= teamsStillNeedingSport`
- **Flex pick in sport**: `undraftedInSport > teamsStillNeedingSport`
### Example 1: Team Flex Limit
- 5 rounds, 3 sports (NFL, NBA, NHL)
- Flex picks available = 5 - 3 = 2
- Scenario:
- Round 1: NFL player (0 flexes used)
- Round 2: NBA player (0 flexes used)
- Round 3: NFL player (1 flex used - duplicate NFL)
- Round 4: NFL player (2 flexes used - second duplicate NFL)
- Round 5: MUST be NHL (all flexes exhausted, NHL is only sport with 0 picks)
### Example 2: Global Sport Availability
- 10 teams, 5 rounds, 3 sports (NFL, NBA, NHL)
- 12 NFL participants total, 3 teams haven't drafted NFL yet, 3 NFL participants left undrafted
- Current team HAS already drafted 1 NFL participant
- Scenario:
- If current team drafts another NFL participant: 2 NFL participants remain
- But 3 teams still need to draft their first NFL participant
- This would make it impossible for all teams to get NFL
- **Result**: Current team is BLOCKED from drafting NFL (even if they have flex picks available)
- Same scenario, but current team has NOT drafted NFL yet:
- If current team drafts NFL participant: 2 NFL participants remain
- And 2 teams still need to draft their first NFL participant (3 - 1)
- This is exactly enough (2 = 2)
- **Result**: Current team is ALLOWED to draft NFL
### Combined Rules
Both constraints must be satisfied:
1. Team must have flex picks available OR not have drafted from this sport
2. AND drafting from this sport won't prevent other teams from fulfilling requirements
## Technical Changes Required
### 1. Data Model Changes
**Location**: `database/schema.ts`
No schema changes needed - we can derive this from existing data:
- Season has `draftRounds` field
- Season is linked to sports via `seasonSports`
- Draft picks are linked to participants which are linked to sports
### 2. Utility Function - Calculate Eligible Sports
**Location**: `app/lib/draft-eligibility.ts` (new file)
Create utility to determine which sports a team can draft from:
```typescript
interface SportAvailability {
sportId: string;
undraftedCount: number;
teamsStillNeeding: number;
canDraftAsFirst: boolean; // undraftedCount >= teamsStillNeeding
canDraftAsFlex: boolean; // undraftedCount > teamsStillNeeding
}
interface DraftEligibility {
teamId: string;
eligibleSportIds: Set<string>;
ineligibleReasons: Record<string, string>; // sportId -> reason
flexPicksUsed: number;
flexPicksAvailable: number;
picksBySport: Record<string, number>;
sportAvailability: Record<string, SportAvailability>;
}
function calculateDraftEligibility(
teamId: string,
teamPicks: Array<{ participant: { sport: { id: string } } }>,
allPicks: Array<{ teamId: string; participant: { sport: { id: string } } }>,
allParticipants: Array<{ id: string; sport: { id: string } }>,
seasonSports: Array<{ id: string; name: string }>,
totalRounds: number,
allTeams: Array<{ id: string }>
): DraftEligibility
```
**Logic**:
**Step 1: Calculate team's flex pick status**
1. Count picks per sport for the team
2. Calculate flex picks used = sum of (picks in sport - 1) for each sport with >1 pick
3. Calculate flex picks available = totalRounds - totalSports
4. Determine if team has flexes remaining: `flexPicksUsed < flexPicksAvailable`
**Step 2: Calculate global sport availability**
For each sport:
1. Count total participants in sport
2. Count drafted participants in sport (from allPicks)
3. Calculate undrafted = total - drafted
4. Count teams with 0 picks in this sport
5. Determine:
- `canDraftAsFirst = undraftedCount >= teamsStillNeeding`
- `canDraftAsFlex = undraftedCount > teamsStillNeeding`
**Step 3: Combine constraints**
For each sport, determine eligibility:
1. Check if team has drafted from sport: `currentTeamHasSport = picksBySport[sportId] > 0`
2. If NOT yet drafted from sport:
- Allowed if: sport availability `canDraftAsFirst` is true
- Reason if blocked: "Only X participants left for Y teams needing this sport"
3. If already drafted from sport (would be flex):
- Allowed if: team has flexes remaining AND sport availability `canDraftAsFlex` is true
- Reason if blocked (flex exhausted): "All flex picks used"
- Reason if blocked (global): "Would prevent other teams from getting this sport"
**Step 4: Return eligibility**
- Set of eligible sport IDs
- Map of ineligible reasons for UI display
### 3. Frontend - Draft Room UI
**Location**: `app/routes/leagues/$leagueId.draft.$seasonId.tsx`
#### Changes to Loader
- Load all sports for the season (already done via `seasonSports`)
- Load ALL participants for the season (already done)
- Load ALL teams for the season (already done via `season.teams`)
- Pass sport count to client
- All data needed for eligibility calculation is available
#### Changes to Component State
- Track team's picks by sport (can derive from `picks` state filtered by `userTeam.id`)
- Calculate eligibility when rendering participant list
#### Changes to Participant Table
- Add eligibility check before rendering buttons
- Disable "Pick" button if sport is not eligible
- Disable "Add to Queue" if sport is not eligible
- Show visual indicator (badge/tooltip) explaining why ineligible
```typescript
// Pseudo-code
const eligibility = calculateDraftEligibility(
userTeam.id,
picks.filter(p => p.team.id === userTeam.id),
picks, // all picks
availableParticipants, // all participants
seasonSports,
season.draftRounds,
season.teams
);
// In participant rendering
const isEligible = eligibility.eligibleSportIds.has(participant.sport.id);
const ineligibleReason = eligibility.ineligibleReasons[participant.sport.id];
```
#### Visual Indicators
- Add a status banner showing:
- Flex picks remaining for current team
- Sport-by-sport breakdown showing current team's picks
- Sport availability warnings (e.g., "Only 2 NFL participants left for 3 teams")
- Show pick counts by sport in My Queue section
- Grayed out rows for ineligible participants
- Tooltip/badge explaining why a participant is ineligible:
- "Flex picks exhausted - must draft from [sports list]"
- "Only X participants left for Y teams needing this sport"
- Combination of both constraints
### 4. Backend - API Validation
**Location**: `app/routes/api/draft/make-pick.tsx`
Add validation before creating pick:
1. Load team's existing picks
2. Load season sports count
3. Calculate eligibility
4. Return 400 error if sport is not eligible
```typescript
// Validation logic
const teamPicks = await getTeamPicks(teamId, seasonId);
const allPicks = await getAllPicksForSeason(seasonId);
const allParticipants = await getAllParticipantsForSeason(seasonId);
const seasonSports = await getSeasonSports(seasonId);
const allTeams = await getTeamsForSeason(seasonId);
const eligibility = calculateDraftEligibility(
teamId,
teamPicks,
allPicks,
allParticipants,
seasonSports,
season.draftRounds,
allTeams
);
if (!eligibility.eligibleSportIds.has(participant.sport.id)) {
const reason = eligibility.ineligibleReasons[participant.sport.id] ||
"Cannot draft from this sport";
return json(
{ error: reason },
{ status: 400 }
);
}
```
### 5. Backend - Autodraft Logic
**Location**: `app/models/draft-pick.ts` (autodraft function)
Update autodraft logic to respect eligibility:
1. Calculate team's eligibility
2. Filter queue by eligible sports only
3. If queue has no eligible participants:
- Fall back to highest EV participant from eligible sports
4. If no eligible participants at all (edge case):
- Log error and pause draft
### 6. Force Manual Pick Dialog
**Location**: `app/routes/leagues/$leagueId.draft.$seasonId.tsx` (Force Manual Pick Dialog)
Update the dialog:
1. Calculate target team's eligibility
2. Filter participant list to only eligible sports
3. Gray out / hide ineligible participants
4. Show status message about sport restrictions
### 7. Commissioner Force Autopick
**Location**: `app/routes/api/draft/force-autopick.tsx`
Use same autodraft logic as regular autopick - it will automatically respect eligibility.
## Implementation Steps
### Phase 1: Core Logic
1. Create `app/lib/draft-eligibility.ts` with calculation function
2. Add unit tests for eligibility calculation covering:
- Team flex pick constraints
- Global sport availability constraints
- Combined constraints
- Edge cases
3. Add helper functions to models:
- Get all picks for season with sport data
- Get all participants for season grouped by sport
- Get all teams for season with their picks
### Phase 2: Backend Validation
1. Update `app/routes/api/draft/make-pick.tsx` with validation
2. Update autodraft logic in `app/models/draft-pick.ts`
3. Update `app/routes/api/draft/force-autopick.tsx`
4. Update `app/routes/api/draft/force-manual-pick.tsx`
### Phase 3: Frontend UI
1. Update draft room loader to include sports count
2. Add eligibility calculation to component
3. Update participant table with disabled states
4. Add visual indicators (badges, tooltips)
5. Update Force Manual Pick dialog
6. Add status banner showing flex picks remaining
### Phase 4: Testing
1. Unit tests for eligibility calculation
2. Integration tests for API validation
3. E2E test for draft flow with restrictions
4. Test autodraft respects restrictions
5. Test force picks respect restrictions
## Edge Cases to Handle
1. **Single sport league**: No global restrictions apply (everyone needs same sport, but flex picks still matter)
2. **More sports than rounds**: Error - invalid configuration (impossible to satisfy)
3. **Queue only has ineligible participants**: Autodraft falls back to available pool filtered by eligibility
4. **All participants in eligible sports are drafted**: Draft becomes impossible - should show error/warning
5. **Mid-draft rule change**: Rules apply based on current season configuration
6. **Last few picks**: Global constraints become very restrictive - may force specific picks
7. **All teams have drafted from a sport**: Global constraint for that sport becomes `undrafted > 0` (no minimum needed)
8. **Participant pool imbalanced**: Some sports have very few participants - may trigger global constraints early
9. **Multiple teams on same pick in round**: Each team's eligibility calculated independently
## Testing Scenarios
### Scenario 1: Team Flex Limit
- 3 sports, 5 rounds, 10 teams
- Team drafts 2 from sport A, 1 from sport B
- Verify sport A is disabled (flexes exhausted), sports B and C are enabled
- Make pick from sport C
- Verify only sport B is enabled
### Scenario 2: Global Constraint - Flex Pick Blocked
- 3 sports, 5 rounds, 8 teams
- Sport A has 10 total participants
- 8 picks made from sport A (2 left)
- 3 teams haven't drafted sport A yet (including current team)
- Current team tries to draft sport A as their first from that sport
- Should be ALLOWED (2 >= 3 - 1, which is 2 >= 2)
- Different team that HAS sport A tries to draft another
- Should be BLOCKED (2 > 3 is false)
### Scenario 3: Global Constraint - First Pick Allowed
- 3 sports, 5 rounds, 6 teams
- Sport B has 8 total participants
- 5 picks made from sport B (3 left)
- 3 teams haven't drafted sport B yet
- Current team (one of the 3) tries to draft sport B
- Should be ALLOWED (3 >= 3)
- After pick: 2 left, 2 teams need it
- Next team tries to draft sport B as first
- Should be ALLOWED (2 >= 2)
### Scenario 4: Autodraft with Global Constraints
- 3 sports, 5 rounds
- Queue has only sport A participants
- Team has 2 flexes used (3 sport A picks)
- Global constraint: only 1 sport A left, 2 teams need it
- Autodraft should skip queue AND skip sport A
- Should pick highest EV from sport B or C that's eligible
### Scenario 5: Force Pick with Combined Constraints
- Commissioner forces pick for team
- Team has exhausted flexes (can only pick from sports not yet drafted)
- Global constraints eliminate one of those sports
- Dialog should only show participants from eligible sports
- Should display clear reason for each ineligible sport
### Scenario 6: Late Draft Deadlock Prevention
- 4 teams, 3 sports, 4 rounds
- Near end of draft, only sport C participants left
- 2 teams haven't drafted sport C
- 2 sport C participants left
- Teams should both be able to complete draft
- Verify global constraint math: 2 >= 2 allows first team, then 1 >= 1 allows second
### Scenario 7: Impossible Draft State
- 5 teams, 3 sports, 5 rounds
- Sport A has only 4 total participants
- 4 teams need sport A, 0 picks from sport A made yet
- System should detect this is impossible early
- Consider adding pre-draft validation for commissioners
## Data Dependencies
### For Eligibility Calculation:
- **Season**: `draftRounds`, `id`
- **Season Sports**: All sports in the season (id, name)
- **All Teams**: List of all teams in the season
- **All Draft Picks**: Complete pick history with team and sport info
- Needed to calculate: picks per sport per team, total drafted per sport
- **All Participants**: Complete participant pool with sport association
- Needed to calculate: undrafted count per sport
- **Current Team Picks**: Filtered list for flex calculation
### Query Optimization:
- Most queries already exist in draft room loader
- Need to add: pick counts grouped by (teamId, sportId)
- Need to add: undrafted participant counts by sportId
- Consider caching eligibility calculation result per render
- Recalculate only when picks array changes
## UI/UX Considerations
1. **Clear feedback**: User should understand why they can't pick certain participants
2. **Status visibility**: Show flex picks remaining prominently
3. **Sport breakdown**: Show picks per sport in a visible location
4. **Error messages**: Clear explanation when API rejects a pick
5. **Queue validation**: Warn if queue contains ineligible participants
## Performance Considerations
- Eligibility calculation complexity:
- Team picks iteration: O(picks per team) = O(rounds) ≈ O(20)
- All picks iteration for sport counts: O(total picks) = O(teams × rounds) ≈ O(200)
- Per-sport calculations: O(sports) ≈ O(3-5)
- **Total: O(teams × rounds)** - acceptable for real-time calculation
- Calculate once per render and memoize result
- Recalculate only when picks state changes (use useMemo)
- No database schema changes needed
- API validation adds one calculation step (same complexity)
- Consider pre-computing pick counts in loader for faster initial render
- Frontend calculation more responsive than API call per participant
## Future Enhancements
1. **Pre-draft validation**:
- Validate season configuration (enough participants per sport for all teams)
- Show warnings to commissioner before starting draft
2. **Queue validation and management**:
- Real-time warnings if queue contains ineligible participants
- Auto-reorder queue suggestions
- "Smart queue" that prioritizes based on scarcity
3. **Draft strategy tools**:
- Recommendations: "Sport X is running low, consider drafting soon"
- Scarcity indicators on participant list
- Probability calculations for participant availability
4. **Configurable rules**:
- League settings to customize flex pick rules
- Option to disable global constraints (less restrictive mode)
- Minimum picks per sport (beyond just 1)
5. **Advanced restrictions**:
- Position-based restrictions within sports (QB, RB, etc.)
- Conference/division restrictions
- Team ownership limits (e.g., max 2 players from same NFL team)
6. **Undo/Correction tools**:
- Commissioner ability to undo picks if rules were violated
- Draft state debugging tools
7. **Analytics**:
- Track how often global constraints triggered
- Identify bottleneck sports
- Post-draft report on draft efficiency