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)
|
||||
.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 * as schema from "~/database/schema";
|
||||
import { eq, and, notInArray, desc } from "drizzle-orm";
|
||||
import { eq, and, notInArray, desc, inArray } from "drizzle-orm";
|
||||
import { getTeamQueue } from "./draft-queue";
|
||||
import { isParticipantDrafted } from "./draft-pick";
|
||||
import { isParticipantDrafted, getDraftPicksWithSports, getTeamDraftPicksWithSports } from "./draft-pick";
|
||||
import { getParticipantsForSeasonWithSports } from "./participant";
|
||||
import { getSeasonSportsSimple } from "./season-sport";
|
||||
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
||||
|
||||
/**
|
||||
* Auto-pick for a team when their timer runs out
|
||||
* 1. Check queue - pick first item if available
|
||||
* 2. If queue empty, pick highest EV participant not drafted
|
||||
* 1. Check queue - pick first eligible item if available
|
||||
* 2. If queue empty, pick highest EV participant not drafted from eligible sports
|
||||
*
|
||||
* Updated to respect Omni league draft eligibility rules
|
||||
*/
|
||||
export async function autoPickForTeam(seasonId: string, teamId: string) {
|
||||
// Check queue first
|
||||
export async function autoPickForTeam(
|
||||
seasonId: string,
|
||||
teamId: string,
|
||||
draftRounds: number,
|
||||
allTeamIds: string[]
|
||||
) {
|
||||
const db = database();
|
||||
|
||||
// Calculate eligibility for this team
|
||||
const allPicks = await getDraftPicksWithSports(seasonId);
|
||||
const teamPicks = await getTeamDraftPicksWithSports(teamId, seasonId);
|
||||
const allParticipants = await getParticipantsForSeasonWithSports(seasonId);
|
||||
const seasonSports = await getSeasonSportsSimple(seasonId);
|
||||
const allTeams = allTeamIds.map((id) => ({ id }));
|
||||
|
||||
const eligibility = calculateDraftEligibility(
|
||||
teamId,
|
||||
teamPicks,
|
||||
allPicks,
|
||||
allParticipants,
|
||||
seasonSports,
|
||||
draftRounds,
|
||||
allTeams
|
||||
);
|
||||
|
||||
// Check queue first - filter by eligible sports
|
||||
const queue = await getTeamQueue(teamId);
|
||||
|
||||
if (queue.length > 0) {
|
||||
// Pick first item from queue
|
||||
const firstInQueue = queue[0];
|
||||
// Get participant details for queue items to check sport eligibility
|
||||
const queueParticipantIds = queue.map((item) => item.participantId);
|
||||
const queueParticipants = await db.query.participants.findMany({
|
||||
where: inArray(schema.participants.id, queueParticipantIds),
|
||||
with: {
|
||||
sportsSeason: {
|
||||
with: {
|
||||
sport: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Verify participant is not already drafted
|
||||
const isDrafted = await isParticipantDrafted(seasonId, firstInQueue.participantId);
|
||||
if (!isDrafted) {
|
||||
return firstInQueue.participantId;
|
||||
}
|
||||
// Try queue items in order, checking both drafted status and eligibility
|
||||
for (const item of queue) {
|
||||
const participant = queueParticipants.find((p) => p.id === item.participantId);
|
||||
if (!participant) continue;
|
||||
|
||||
// If first item was drafted, try next items in queue
|
||||
for (const item of queue.slice(1)) {
|
||||
const sportId = participant.sportsSeason.sport.id;
|
||||
const isEligible = eligibility.eligibleSportIds.has(sportId);
|
||||
const isDrafted = await isParticipantDrafted(seasonId, item.participantId);
|
||||
if (!isDrafted) {
|
||||
|
||||
if (!isDrafted && isEligible) {
|
||||
return item.participantId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Queue is empty or all queued players drafted - pick highest EV available
|
||||
return await getTopAvailableParticipant(seasonId);
|
||||
// Queue is empty or all queued players drafted/ineligible
|
||||
// Pick highest EV available from eligible sports
|
||||
return await getTopAvailableParticipant(seasonId, eligibility.eligibleSportIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the highest EV participant that hasn't been drafted yet
|
||||
* Updated to filter by eligible sports
|
||||
*/
|
||||
export async function getTopAvailableParticipant(seasonId: string) {
|
||||
export async function getTopAvailableParticipant(
|
||||
seasonId: string,
|
||||
eligibleSportIds?: Set<string>
|
||||
) {
|
||||
const db = database();
|
||||
|
||||
// Get all drafted participant IDs
|
||||
|
|
@ -50,13 +94,41 @@ export async function getTopAvailableParticipant(seasonId: string) {
|
|||
|
||||
const draftedIds = draftedPicks.map((p: { participantId: string }) => p.participantId);
|
||||
|
||||
// Get all participants from season sports
|
||||
const seasonSportsData = await db
|
||||
.select({ sportsSeasonId: schema.seasonSports.sportsSeasonId })
|
||||
.from(schema.seasonSports)
|
||||
.where(eq(schema.seasonSports.seasonId, seasonId));
|
||||
// Get all participants from season sports, filtered by eligible sports if provided
|
||||
let seasonSportsData;
|
||||
if (eligibleSportIds && eligibleSportIds.size > 0) {
|
||||
// Filter to only eligible sports
|
||||
seasonSportsData = await db
|
||||
.select({
|
||||
sportsSeasonId: schema.seasonSports.sportsSeasonId,
|
||||
sportId: schema.sports.id,
|
||||
})
|
||||
.from(schema.seasonSports)
|
||||
.innerJoin(
|
||||
schema.sportsSeasons,
|
||||
eq(schema.seasonSports.sportsSeasonId, schema.sportsSeasons.id)
|
||||
)
|
||||
.innerJoin(
|
||||
schema.sports,
|
||||
eq(schema.sportsSeasons.sportId, schema.sports.id)
|
||||
)
|
||||
.where(eq(schema.seasonSports.seasonId, seasonId));
|
||||
|
||||
const sportsSeasonIds = seasonSportsData.map((s: { sportsSeasonId: string }) => s.sportsSeasonId);
|
||||
// Filter to only eligible sports
|
||||
seasonSportsData = seasonSportsData.filter((s: { sportId: string }) =>
|
||||
eligibleSportIds.has(s.sportId)
|
||||
);
|
||||
} else {
|
||||
// No filtering - get all sports
|
||||
seasonSportsData = await db
|
||||
.select({ sportsSeasonId: schema.seasonSports.sportsSeasonId })
|
||||
.from(schema.seasonSports)
|
||||
.where(eq(schema.seasonSports.seasonId, seasonId));
|
||||
}
|
||||
|
||||
const sportsSeasonIds = seasonSportsData.map(
|
||||
(s: { sportsSeasonId: string }) => s.sportsSeasonId
|
||||
);
|
||||
|
||||
if (sportsSeasonIds.length === 0) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
|
||||
|
|
@ -100,3 +100,49 @@ export async function copyParticipantsFromSeason(
|
|||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all participants for a season with sport information
|
||||
* Used for draft eligibility calculations
|
||||
*/
|
||||
export async function getParticipantsForSeasonWithSports(seasonId: string) {
|
||||
const db = database();
|
||||
|
||||
// First get all sports seasons for this season
|
||||
const seasonSports = await db.query.seasonSports.findMany({
|
||||
where: eq(schema.seasonSports.seasonId, seasonId),
|
||||
});
|
||||
|
||||
if (seasonSports.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sportsSeasonIds = seasonSports.map((ss) => ss.sportsSeasonId);
|
||||
|
||||
// Get all participants for these sports seasons with sport info
|
||||
const participants = await db
|
||||
.select({
|
||||
id: schema.participants.id,
|
||||
name: schema.participants.name,
|
||||
sport: {
|
||||
id: schema.sports.id,
|
||||
name: schema.sports.name,
|
||||
},
|
||||
})
|
||||
.from(schema.participants)
|
||||
.innerJoin(
|
||||
schema.sportsSeasons,
|
||||
eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id)
|
||||
)
|
||||
.innerJoin(
|
||||
schema.sports,
|
||||
eq(schema.sportsSeasons.sportId, schema.sports.id)
|
||||
)
|
||||
.where(
|
||||
sportsSeasonIds.length === 1
|
||||
? eq(schema.participants.sportsSeasonId, sportsSeasonIds[0])
|
||||
: inArray(schema.participants.sportsSeasonId, sportsSeasonIds)
|
||||
);
|
||||
|
||||
return participants;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, and, notInArray, desc, asc, inArray } from "drizzle-orm";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { autoPickForTeam } from "~/models/draft-utils";
|
||||
|
||||
export async function action(args: any) {
|
||||
const { request } = args;
|
||||
|
|
@ -40,91 +41,7 @@ export async function action(args: any) {
|
|||
return Response.json({ error: "Only commissioner can force autopick" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Get team's queue
|
||||
const teamQueue = await db.query.draftQueue.findMany({
|
||||
where: eq(schema.draftQueue.teamId, teamId),
|
||||
orderBy: schema.draftQueue.queuePosition,
|
||||
with: {
|
||||
participant: {
|
||||
with: {
|
||||
sportsSeason: {
|
||||
with: {
|
||||
sport: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Get already drafted participant IDs
|
||||
const draftPicks = await db.query.draftPicks.findMany({
|
||||
where: eq(schema.draftPicks.seasonId, seasonId),
|
||||
});
|
||||
const draftedParticipantIds = draftPicks.map((p) => p.participantId);
|
||||
|
||||
let participantToPick = null;
|
||||
|
||||
// 1. Try to pick from queue (first non-drafted participant)
|
||||
for (const queueItem of teamQueue) {
|
||||
if (!draftedParticipantIds.includes(queueItem.participantId)) {
|
||||
participantToPick = queueItem.participant;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. If no valid queue item, pick highest EV available participant
|
||||
if (!participantToPick) {
|
||||
// Get sports seasons for this season
|
||||
const seasonSports = await db.query.seasonSports.findMany({
|
||||
where: eq(schema.seasonSports.seasonId, seasonId),
|
||||
});
|
||||
|
||||
const sportsSeasonIds = seasonSports.map((ss) => ss.sportsSeasonId);
|
||||
|
||||
if (sportsSeasonIds.length > 0) {
|
||||
const availableParticipants = await db
|
||||
.select({
|
||||
id: schema.participants.id,
|
||||
name: schema.participants.name,
|
||||
expectedValue: schema.participants.expectedValue,
|
||||
sportsSeasonId: schema.participants.sportsSeasonId,
|
||||
})
|
||||
.from(schema.participants)
|
||||
.where(
|
||||
and(
|
||||
sportsSeasonIds.length === 1
|
||||
? eq(schema.participants.sportsSeasonId, sportsSeasonIds[0])
|
||||
: inArray(schema.participants.sportsSeasonId, sportsSeasonIds),
|
||||
draftedParticipantIds.length > 0
|
||||
? notInArray(schema.participants.id, draftedParticipantIds)
|
||||
: undefined
|
||||
)
|
||||
)
|
||||
.orderBy(desc(schema.participants.expectedValue), asc(schema.participants.name))
|
||||
.limit(1);
|
||||
|
||||
if (availableParticipants.length > 0) {
|
||||
// Get full participant with sport info
|
||||
participantToPick = await db.query.participants.findFirst({
|
||||
where: eq(schema.participants.id, availableParticipants[0].id),
|
||||
with: {
|
||||
sportsSeason: {
|
||||
with: {
|
||||
sport: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!participantToPick) {
|
||||
return Response.json({ error: "No available participants to pick" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Calculate round and pickInRound
|
||||
// Get draft slots to pass team IDs
|
||||
const draftSlots = await db.query.draftSlots.findMany({
|
||||
where: eq(schema.draftSlots.seasonId, seasonId),
|
||||
orderBy: schema.draftSlots.draftOrder,
|
||||
|
|
@ -133,6 +50,37 @@ export async function action(args: any) {
|
|||
},
|
||||
});
|
||||
|
||||
const allTeamIds = draftSlots.map((slot) => slot.teamId);
|
||||
|
||||
// Use autoPickForTeam which now respects eligibility
|
||||
const participantId = await autoPickForTeam(
|
||||
seasonId,
|
||||
teamId,
|
||||
season.draftRounds,
|
||||
allTeamIds
|
||||
);
|
||||
|
||||
if (!participantId) {
|
||||
return Response.json({ error: "No eligible participants available to pick" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get participant details
|
||||
const participantToPick = await db.query.participants.findFirst({
|
||||
where: eq(schema.participants.id, participantId),
|
||||
with: {
|
||||
sportsSeason: {
|
||||
with: {
|
||||
sport: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!participantToPick) {
|
||||
return Response.json({ error: "Participant not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Calculate round and pickInRound
|
||||
const totalTeams = draftSlots.length;
|
||||
const currentRound = Math.ceil(pickNumber / totalTeams);
|
||||
const isEvenRound = currentRound % 2 === 0;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { getAuth } from "@clerk/react-router/server";
|
|||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
||||
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
|
||||
import { getParticipantsForSeasonWithSports } from "~/models/participant";
|
||||
import { getSeasonSportsSimple } from "~/models/season-sport";
|
||||
|
||||
export async function action(args: any) {
|
||||
const { request } = args;
|
||||
|
|
@ -86,6 +90,31 @@ export async function action(args: any) {
|
|||
pickInRound = totalTeams - pickInRound + 1;
|
||||
}
|
||||
|
||||
// ELIGIBILITY VALIDATION: Check if team can draft from this sport
|
||||
const allPicks = await getDraftPicksWithSports(seasonId);
|
||||
const teamPicks = await getTeamDraftPicksWithSports(teamId, seasonId);
|
||||
const allParticipants = await getParticipantsForSeasonWithSports(seasonId);
|
||||
const seasonSports = await getSeasonSportsSimple(seasonId);
|
||||
|
||||
// Get all teams for the season
|
||||
const allTeams = draftSlots.map((slot) => ({ id: slot.teamId }));
|
||||
|
||||
const eligibility = calculateDraftEligibility(
|
||||
teamId,
|
||||
teamPicks,
|
||||
allPicks,
|
||||
allParticipants,
|
||||
seasonSports,
|
||||
season.draftRounds,
|
||||
allTeams
|
||||
);
|
||||
|
||||
const sportId = participant.sportsSeason.sport.id;
|
||||
if (!eligibility.eligibleSportIds.has(sportId)) {
|
||||
const reason = eligibility.ineligibleReasons[sportId] || "Cannot draft from this sport";
|
||||
return Response.json({ error: reason }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create the draft pick
|
||||
const [draftPick] = await db
|
||||
.insert(schema.draftPicks)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { getAuth } from "@clerk/react-router/server";
|
|||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
||||
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
|
||||
import { getParticipantsForSeasonWithSports } from "~/models/participant";
|
||||
import { getSeasonSportsSimple } from "~/models/season-sport";
|
||||
|
||||
export async function action(args: any) {
|
||||
const { request } = args;
|
||||
|
|
@ -97,6 +101,31 @@ export async function action(args: any) {
|
|||
return Response.json({ error: "Participant not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// ELIGIBILITY VALIDATION: Check if team can draft from this sport
|
||||
const allPicks = await getDraftPicksWithSports(seasonId);
|
||||
const teamPicks = await getTeamDraftPicksWithSports(currentDraftSlot.teamId, seasonId);
|
||||
const allParticipants = await getParticipantsForSeasonWithSports(seasonId);
|
||||
const seasonSports = await getSeasonSportsSimple(seasonId);
|
||||
|
||||
// Get all teams for the season
|
||||
const allTeams = draftSlots.map((slot) => ({ id: slot.teamId }));
|
||||
|
||||
const eligibility = calculateDraftEligibility(
|
||||
currentDraftSlot.teamId,
|
||||
teamPicks,
|
||||
allPicks,
|
||||
allParticipants,
|
||||
seasonSports,
|
||||
season.draftRounds,
|
||||
allTeams
|
||||
);
|
||||
|
||||
const sportId = participant.sportsSeason.sport.id;
|
||||
if (!eligibility.eligibleSportIds.has(sportId)) {
|
||||
const reason = eligibility.ineligibleReasons[sportId] || "Cannot draft from this sport";
|
||||
return Response.json({ error: reason }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create the draft pick
|
||||
const [draftPick] = await db
|
||||
.insert(schema.draftPicks)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { eq, and, asc, desc, inArray } from "drizzle-orm";
|
|||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { useDraftSocket } from "~/hooks/useDraftSocket";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { Card } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
ContextMenuTrigger,
|
||||
} from "~/components/ui/context-menu";
|
||||
import { AutodraftSettings } from "~/components/AutodraftSettings";
|
||||
import { calculateDraftEligibility, getEligibilitySummary } from "~/lib/draft-eligibility";
|
||||
|
||||
export async function loader(args: any) {
|
||||
const { params } = args;
|
||||
|
|
@ -247,6 +248,101 @@ export default function DraftRoom() {
|
|||
teamId: string;
|
||||
} | null>(null);
|
||||
|
||||
// Calculate draft eligibility for current user's team
|
||||
const eligibility = useMemo(() => {
|
||||
if (!userTeam) return null;
|
||||
|
||||
// Transform picks data to match eligibility function signature
|
||||
const transformedPicks = picks.map((p: any) => ({
|
||||
teamId: p.team.id,
|
||||
participant: {
|
||||
id: p.participant.id,
|
||||
sport: {
|
||||
id: p.sport.id,
|
||||
name: p.sport.name,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const userTeamPicks = transformedPicks.filter(
|
||||
(p: any) => p.teamId === userTeam.id
|
||||
);
|
||||
|
||||
// Transform participants to match signature
|
||||
const transformedParticipants = availableParticipants.map((p: any) => ({
|
||||
id: p.id,
|
||||
sport: {
|
||||
id: p.sport.id,
|
||||
name: p.sport.name,
|
||||
},
|
||||
}));
|
||||
|
||||
// Get unique sports from availableParticipants
|
||||
const sportsMap = new Map();
|
||||
availableParticipants.forEach((p: any) => {
|
||||
if (!sportsMap.has(p.sport.id)) {
|
||||
sportsMap.set(p.sport.id, { id: p.sport.id, name: p.sport.name });
|
||||
}
|
||||
});
|
||||
const seasonSportsData = Array.from(sportsMap.values());
|
||||
|
||||
return calculateDraftEligibility(
|
||||
userTeam.id,
|
||||
userTeamPicks,
|
||||
transformedPicks,
|
||||
transformedParticipants,
|
||||
seasonSportsData,
|
||||
season.draftRounds,
|
||||
season.teams
|
||||
);
|
||||
}, [userTeam, picks, availableParticipants, season]);
|
||||
|
||||
// Calculate eligibility for force manual pick dialog (selected team)
|
||||
const forcePickEligibility = useMemo(() => {
|
||||
if (!selectedPickSlot) return null;
|
||||
|
||||
const transformedPicks = picks.map((p: any) => ({
|
||||
teamId: p.team.id,
|
||||
participant: {
|
||||
id: p.participant.id,
|
||||
sport: {
|
||||
id: p.sport.id,
|
||||
name: p.sport.name,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const selectedTeamPicks = transformedPicks.filter(
|
||||
(p: any) => p.teamId === selectedPickSlot.teamId
|
||||
);
|
||||
|
||||
const transformedParticipants = availableParticipants.map((p: any) => ({
|
||||
id: p.id,
|
||||
sport: {
|
||||
id: p.sport.id,
|
||||
name: p.sport.name,
|
||||
},
|
||||
}));
|
||||
|
||||
const sportsMap = new Map();
|
||||
availableParticipants.forEach((p: any) => {
|
||||
if (!sportsMap.has(p.sport.id)) {
|
||||
sportsMap.set(p.sport.id, { id: p.sport.id, name: p.sport.name });
|
||||
}
|
||||
});
|
||||
const seasonSportsData = Array.from(sportsMap.values());
|
||||
|
||||
return calculateDraftEligibility(
|
||||
selectedPickSlot.teamId,
|
||||
selectedTeamPicks,
|
||||
transformedPicks,
|
||||
transformedParticipants,
|
||||
seasonSportsData,
|
||||
season.draftRounds,
|
||||
season.teams
|
||||
);
|
||||
}, [selectedPickSlot, picks, availableParticipants, season]);
|
||||
|
||||
// Listen for new picks from other users
|
||||
useEffect(() => {
|
||||
const handlePickMade = (data: any) => {
|
||||
|
|
@ -837,6 +933,50 @@ export default function DraftRoom() {
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Eligibility Status Banner */}
|
||||
{eligibility && (
|
||||
<div className="mb-4 p-3 bg-muted/50 rounded-lg border">
|
||||
<div className="text-sm font-semibold mb-2">Draft Status</div>
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span>Flex Picks:</span>
|
||||
<span className={eligibility.flexPicksUsed >= eligibility.flexPicksAvailable ? "text-red-600 dark:text-red-400 font-semibold" : "text-green-600 dark:text-green-400"}>
|
||||
{eligibility.flexPicksUsed} / {eligibility.flexPicksAvailable}
|
||||
</span>
|
||||
</div>
|
||||
{Object.entries(eligibility.picksBySport).length > 0 && (
|
||||
<>
|
||||
<div className="font-semibold mt-2 mb-1">Picks by Sport:</div>
|
||||
{Object.entries(eligibility.picksBySport).map(([sportId, count]) => {
|
||||
const sportInfo = eligibility.sportAvailability[sportId];
|
||||
return (
|
||||
<div key={sportId} className="flex justify-between items-center pl-2">
|
||||
<span>{sportInfo?.sportName || sportId}:</span>
|
||||
<span className="font-mono">{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{Object.keys(eligibility.ineligibleReasons).length > 0 && (
|
||||
<>
|
||||
<div className="font-semibold mt-2 mb-1 text-red-600 dark:text-red-400">
|
||||
Restrictions:
|
||||
</div>
|
||||
{Object.entries(eligibility.ineligibleReasons).slice(0, 2).map(([sportId, reason]) => {
|
||||
const sportInfo = eligibility.sportAvailability[sportId];
|
||||
return (
|
||||
<div key={sportId} className="text-xs text-red-600 dark:text-red-400 pl-2">
|
||||
{sportInfo?.sportName}: {reason}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{queue.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm text-center py-8">
|
||||
Click participants to add to your queue
|
||||
|
|
@ -1010,18 +1150,27 @@ export default function DraftRoom() {
|
|||
(item: any) => item.participantId === participant.id
|
||||
);
|
||||
|
||||
// Check eligibility
|
||||
const isEligible = eligibility
|
||||
? eligibility.eligibleSportIds.has(participant.sport.id)
|
||||
: true;
|
||||
const ineligibleReason = eligibility?.ineligibleReasons[participant.sport.id];
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={participant.id}
|
||||
className={`border-t transition-colors ${
|
||||
isDrafted
|
||||
? "bg-muted/50 opacity-60"
|
||||
: "hover:bg-muted/50"
|
||||
: !isEligible
|
||||
? "bg-red-50/30 dark:bg-red-950/20 opacity-75"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
title={!isEligible && !isDrafted ? ineligibleReason : undefined}
|
||||
>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
<span className={`font-medium ${!isEligible && !isDrafted ? "text-muted-foreground" : ""}`}>
|
||||
{participant.name}
|
||||
</span>
|
||||
{isDrafted && (
|
||||
|
|
@ -1032,7 +1181,16 @@ export default function DraftRoom() {
|
|||
Drafted
|
||||
</Badge>
|
||||
)}
|
||||
{isInQueue && !isDrafted && (
|
||||
{!isDrafted && !isEligible && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="text-xs"
|
||||
title={ineligibleReason}
|
||||
>
|
||||
Ineligible
|
||||
</Badge>
|
||||
)}
|
||||
{isInQueue && !isDrafted && isEligible && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="text-xs"
|
||||
|
|
@ -1060,6 +1218,8 @@ export default function DraftRoom() {
|
|||
onClick={() =>
|
||||
handleMakePick(participant.id)
|
||||
}
|
||||
disabled={!isEligible}
|
||||
title={!isEligible ? ineligibleReason : undefined}
|
||||
>
|
||||
Pick
|
||||
</Button>
|
||||
|
|
@ -1071,7 +1231,8 @@ export default function DraftRoom() {
|
|||
onClick={() =>
|
||||
handleAddToQueue(participant.id)
|
||||
}
|
||||
title="Add to queue"
|
||||
title={!isEligible ? ineligibleReason : "Add to queue"}
|
||||
disabled={!isEligible}
|
||||
>
|
||||
<span className="text-lg">+</span>
|
||||
</Button>
|
||||
|
|
@ -1107,6 +1268,8 @@ export default function DraftRoom() {
|
|||
onClick={() =>
|
||||
handleAddToQueue(participant.id)
|
||||
}
|
||||
disabled={!isEligible}
|
||||
title={!isEligible ? ineligibleReason : undefined}
|
||||
>
|
||||
Add to Queue
|
||||
</Button>
|
||||
|
|
@ -1201,32 +1364,60 @@ export default function DraftRoom() {
|
|||
<tbody>
|
||||
{filteredParticipants
|
||||
.filter((p: any) => !draftedParticipantIds.has(p.id))
|
||||
.map((participant: any) => (
|
||||
<tr
|
||||
key={participant.id}
|
||||
className="border-t hover:bg-muted/50"
|
||||
>
|
||||
<td className="p-3 font-medium">
|
||||
{participant.name}
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{participant.sport.name}
|
||||
</td>
|
||||
<td className="p-3 text-right font-mono font-semibold">
|
||||
{participant.expectedValue}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleForceManualPick(participant.id)
|
||||
}
|
||||
>
|
||||
Draft
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
.map((participant: any) => {
|
||||
// Check eligibility for force pick team
|
||||
const isEligible = forcePickEligibility
|
||||
? forcePickEligibility.eligibleSportIds.has(participant.sport.id)
|
||||
: true;
|
||||
const ineligibleReason = forcePickEligibility?.ineligibleReasons[participant.sport.id];
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={participant.id}
|
||||
className={`border-t ${
|
||||
isEligible
|
||||
? "hover:bg-muted/50"
|
||||
: "bg-red-50/30 dark:bg-red-950/20 opacity-60"
|
||||
}`}
|
||||
title={!isEligible ? ineligibleReason : undefined}
|
||||
>
|
||||
<td className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-medium ${!isEligible ? "text-muted-foreground" : ""}`}>
|
||||
{participant.name}
|
||||
</span>
|
||||
{!isEligible && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="text-xs"
|
||||
title={ineligibleReason}
|
||||
>
|
||||
Ineligible
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-3 text-muted-foreground">
|
||||
{participant.sport.name}
|
||||
</td>
|
||||
<td className="p-3 text-right font-mono font-semibold">
|
||||
{participant.expectedValue}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleForceManualPick(participant.id)
|
||||
}
|
||||
disabled={!isEligible}
|
||||
title={!isEligible ? ineligibleReason : undefined}
|
||||
>
|
||||
Draft
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
|||
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