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:
parent
f3050908e8
commit
decb28dc19
12 changed files with 2290 additions and 150 deletions
753
app/lib/__tests__/draft-eligibility.test.ts
Normal file
753
app/lib/__tests__/draft-eligibility.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
195
app/lib/draft-eligibility.ts
Normal file
195
app/lib/draft-eligibility.ts
Normal 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"}`;
|
||||||
|
}
|
||||||
|
|
@ -70,3 +70,97 @@ export async function deleteAllDraftPicks(seasonId: string) {
|
||||||
.delete(schema.draftPicks)
|
.delete(schema.draftPicks)
|
||||||
.where(eq(schema.draftPicks.seasonId, seasonId));
|
.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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,89 @@
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
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 { 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
|
* Auto-pick for a team when their timer runs out
|
||||||
* 1. Check queue - pick first item if available
|
* 1. Check queue - pick first eligible item if available
|
||||||
* 2. If queue empty, pick highest EV participant not drafted
|
* 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) {
|
export async function autoPickForTeam(
|
||||||
// Check queue first
|
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);
|
const queue = await getTeamQueue(teamId);
|
||||||
|
|
||||||
if (queue.length > 0) {
|
if (queue.length > 0) {
|
||||||
// Pick first item from queue
|
// Get participant details for queue items to check sport eligibility
|
||||||
const firstInQueue = queue[0];
|
const queueParticipantIds = queue.map((item) => item.participantId);
|
||||||
|
const queueParticipants = await db.query.participants.findMany({
|
||||||
// Verify participant is not already drafted
|
where: inArray(schema.participants.id, queueParticipantIds),
|
||||||
const isDrafted = await isParticipantDrafted(seasonId, firstInQueue.participantId);
|
with: {
|
||||||
if (!isDrafted) {
|
sportsSeason: {
|
||||||
return firstInQueue.participantId;
|
with: {
|
||||||
}
|
sport: true,
|
||||||
|
},
|
||||||
// If first item was drafted, try next items in queue
|
},
|
||||||
for (const item of queue.slice(1)) {
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
const sportId = participant.sportsSeason.sport.id;
|
||||||
|
const isEligible = eligibility.eligibleSportIds.has(sportId);
|
||||||
const isDrafted = await isParticipantDrafted(seasonId, item.participantId);
|
const isDrafted = await isParticipantDrafted(seasonId, item.participantId);
|
||||||
if (!isDrafted) {
|
|
||||||
|
if (!isDrafted && isEligible) {
|
||||||
return item.participantId;
|
return item.participantId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue is empty or all queued players drafted - pick highest EV available
|
// Queue is empty or all queued players drafted/ineligible
|
||||||
return await getTopAvailableParticipant(seasonId);
|
// Pick highest EV available from eligible sports
|
||||||
|
return await getTopAvailableParticipant(seasonId, eligibility.eligibleSportIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the highest EV participant that hasn't been drafted yet
|
* 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();
|
const db = database();
|
||||||
|
|
||||||
// Get all drafted participant IDs
|
// Get all drafted participant IDs
|
||||||
|
|
@ -50,14 +94,42 @@ export async function getTopAvailableParticipant(seasonId: string) {
|
||||||
|
|
||||||
const draftedIds = draftedPicks.map((p: { participantId: string }) => p.participantId);
|
const draftedIds = draftedPicks.map((p: { participantId: string }) => p.participantId);
|
||||||
|
|
||||||
// Get all participants from season sports
|
// Get all participants from season sports, filtered by eligible sports if provided
|
||||||
const seasonSportsData = await db
|
let seasonSportsData;
|
||||||
.select({ sportsSeasonId: schema.seasonSports.sportsSeasonId })
|
if (eligibleSportIds && eligibleSportIds.size > 0) {
|
||||||
.from(schema.seasonSports)
|
// Filter to only eligible sports
|
||||||
.where(eq(schema.seasonSports.seasonId, seasonId));
|
seasonSportsData = await db
|
||||||
|
.select({
|
||||||
const sportsSeasonIds = seasonSportsData.map((s: { sportsSeasonId: string }) => s.sportsSeasonId);
|
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));
|
||||||
|
|
||||||
|
// 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) {
|
if (sportsSeasonIds.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ export async function applySportsFromTemplate(
|
||||||
templateId: string
|
templateId: string
|
||||||
): Promise<SeasonSport[]> {
|
): Promise<SeasonSport[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
|
|
||||||
// Get all sports from the template
|
// Get all sports from the template
|
||||||
const templateSports = await db.query.seasonTemplateSports.findMany({
|
const templateSports = await db.query.seasonTemplateSports.findMany({
|
||||||
where: eq(schema.seasonTemplateSports.templateId, templateId),
|
where: eq(schema.seasonTemplateSports.templateId, templateId),
|
||||||
|
|
@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { getAuth } from "@clerk/react-router/server";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
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) {
|
export async function action(args: any) {
|
||||||
const { request } = args;
|
const { request } = args;
|
||||||
|
|
@ -40,91 +41,7 @@ export async function action(args: any) {
|
||||||
return Response.json({ error: "Only commissioner can force autopick" }, { status: 403 });
|
return Response.json({ error: "Only commissioner can force autopick" }, { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get team's queue
|
// Get draft slots to pass team IDs
|
||||||
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
|
|
||||||
const draftSlots = await db.query.draftSlots.findMany({
|
const draftSlots = await db.query.draftSlots.findMany({
|
||||||
where: eq(schema.draftSlots.seasonId, seasonId),
|
where: eq(schema.draftSlots.seasonId, seasonId),
|
||||||
orderBy: schema.draftSlots.draftOrder,
|
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 totalTeams = draftSlots.length;
|
||||||
const currentRound = Math.ceil(pickNumber / totalTeams);
|
const currentRound = Math.ceil(pickNumber / totalTeams);
|
||||||
const isEvenRound = currentRound % 2 === 0;
|
const isEvenRound = currentRound % 2 === 0;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@ import { getAuth } from "@clerk/react-router/server";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
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) {
|
export async function action(args: any) {
|
||||||
const { request } = args;
|
const { request } = args;
|
||||||
|
|
@ -86,6 +90,31 @@ export async function action(args: any) {
|
||||||
pickInRound = totalTeams - pickInRound + 1;
|
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
|
// Create the draft pick
|
||||||
const [draftPick] = await db
|
const [draftPick] = await db
|
||||||
.insert(schema.draftPicks)
|
.insert(schema.draftPicks)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@ import { getAuth } from "@clerk/react-router/server";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq, and } from "drizzle-orm";
|
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) {
|
export async function action(args: any) {
|
||||||
const { request } = args;
|
const { request } = args;
|
||||||
|
|
@ -97,6 +101,31 @@ export async function action(args: any) {
|
||||||
return Response.json({ error: "Participant not found" }, { status: 404 });
|
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
|
// Create the draft pick
|
||||||
const [draftPick] = await db
|
const [draftPick] = await db
|
||||||
.insert(schema.draftPicks)
|
.insert(schema.draftPicks)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { eq, and, asc, desc, inArray } from "drizzle-orm";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { useDraftSocket } from "~/hooks/useDraftSocket";
|
import { useDraftSocket } from "~/hooks/useDraftSocket";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useMemo } from "react";
|
||||||
import { Card } from "~/components/ui/card";
|
import { Card } from "~/components/ui/card";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "~/components/ui/context-menu";
|
} from "~/components/ui/context-menu";
|
||||||
import { AutodraftSettings } from "~/components/AutodraftSettings";
|
import { AutodraftSettings } from "~/components/AutodraftSettings";
|
||||||
|
import { calculateDraftEligibility, getEligibilitySummary } from "~/lib/draft-eligibility";
|
||||||
|
|
||||||
export async function loader(args: any) {
|
export async function loader(args: any) {
|
||||||
const { params } = args;
|
const { params } = args;
|
||||||
|
|
@ -247,6 +248,101 @@ export default function DraftRoom() {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
} | null>(null);
|
} | 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
|
// Listen for new picks from other users
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePickMade = (data: any) => {
|
const handlePickMade = (data: any) => {
|
||||||
|
|
@ -837,6 +933,50 @@ export default function DraftRoom() {
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 ? (
|
{queue.length === 0 ? (
|
||||||
<p className="text-muted-foreground text-sm text-center py-8">
|
<p className="text-muted-foreground text-sm text-center py-8">
|
||||||
Click participants to add to your queue
|
Click participants to add to your queue
|
||||||
|
|
@ -1010,18 +1150,27 @@ export default function DraftRoom() {
|
||||||
(item: any) => item.participantId === participant.id
|
(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 (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={participant.id}
|
key={participant.id}
|
||||||
className={`border-t transition-colors ${
|
className={`border-t transition-colors ${
|
||||||
isDrafted
|
isDrafted
|
||||||
? "bg-muted/50 opacity-60"
|
? "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">
|
<td className="p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium">
|
<span className={`font-medium ${!isEligible && !isDrafted ? "text-muted-foreground" : ""}`}>
|
||||||
{participant.name}
|
{participant.name}
|
||||||
</span>
|
</span>
|
||||||
{isDrafted && (
|
{isDrafted && (
|
||||||
|
|
@ -1032,7 +1181,16 @@ export default function DraftRoom() {
|
||||||
Drafted
|
Drafted
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{isInQueue && !isDrafted && (
|
{!isDrafted && !isEligible && (
|
||||||
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="text-xs"
|
||||||
|
title={ineligibleReason}
|
||||||
|
>
|
||||||
|
Ineligible
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isInQueue && !isDrafted && isEligible && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="default"
|
variant="default"
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
|
|
@ -1060,6 +1218,8 @@ export default function DraftRoom() {
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleMakePick(participant.id)
|
handleMakePick(participant.id)
|
||||||
}
|
}
|
||||||
|
disabled={!isEligible}
|
||||||
|
title={!isEligible ? ineligibleReason : undefined}
|
||||||
>
|
>
|
||||||
Pick
|
Pick
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -1071,7 +1231,8 @@ export default function DraftRoom() {
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleAddToQueue(participant.id)
|
handleAddToQueue(participant.id)
|
||||||
}
|
}
|
||||||
title="Add to queue"
|
title={!isEligible ? ineligibleReason : "Add to queue"}
|
||||||
|
disabled={!isEligible}
|
||||||
>
|
>
|
||||||
<span className="text-lg">+</span>
|
<span className="text-lg">+</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -1107,6 +1268,8 @@ export default function DraftRoom() {
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleAddToQueue(participant.id)
|
handleAddToQueue(participant.id)
|
||||||
}
|
}
|
||||||
|
disabled={!isEligible}
|
||||||
|
title={!isEligible ? ineligibleReason : undefined}
|
||||||
>
|
>
|
||||||
Add to Queue
|
Add to Queue
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -1201,32 +1364,60 @@ export default function DraftRoom() {
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredParticipants
|
{filteredParticipants
|
||||||
.filter((p: any) => !draftedParticipantIds.has(p.id))
|
.filter((p: any) => !draftedParticipantIds.has(p.id))
|
||||||
.map((participant: any) => (
|
.map((participant: any) => {
|
||||||
<tr
|
// Check eligibility for force pick team
|
||||||
key={participant.id}
|
const isEligible = forcePickEligibility
|
||||||
className="border-t hover:bg-muted/50"
|
? forcePickEligibility.eligibleSportIds.has(participant.sport.id)
|
||||||
>
|
: true;
|
||||||
<td className="p-3 font-medium">
|
const ineligibleReason = forcePickEligibility?.ineligibleReasons[participant.sport.id];
|
||||||
{participant.name}
|
|
||||||
</td>
|
return (
|
||||||
<td className="p-3 text-muted-foreground">
|
<tr
|
||||||
{participant.sport.name}
|
key={participant.id}
|
||||||
</td>
|
className={`border-t ${
|
||||||
<td className="p-3 text-right font-mono font-semibold">
|
isEligible
|
||||||
{participant.expectedValue}
|
? "hover:bg-muted/50"
|
||||||
</td>
|
: "bg-red-50/30 dark:bg-red-950/20 opacity-60"
|
||||||
<td className="p-3 text-right">
|
}`}
|
||||||
<Button
|
title={!isEligible ? ineligibleReason : undefined}
|
||||||
size="sm"
|
>
|
||||||
onClick={() =>
|
<td className="p-3">
|
||||||
handleForceManualPick(participant.id)
|
<div className="flex items-center gap-2">
|
||||||
}
|
<span className={`font-medium ${!isEligible ? "text-muted-foreground" : ""}`}>
|
||||||
>
|
{participant.name}
|
||||||
Draft
|
</span>
|
||||||
</Button>
|
{!isEligible && (
|
||||||
</td>
|
<Badge
|
||||||
</tr>
|
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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
336
plans/omni-league-draft-rules-IMPLEMENTATION-SUMMARY.md
Normal file
336
plans/omni-league-draft-rules-IMPLEMENTATION-SUMMARY.md
Normal 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
|
||||||
424
plans/omni-league-draft-rules.md
Normal file
424
plans/omni-league-draft-rules.md
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue