From decb28dc1923fc9f2cec7de224c6a0d642d22236 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Fri, 24 Oct 2025 21:12:07 -0700 Subject: [PATCH] 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 --- app/lib/__tests__/draft-eligibility.test.ts | 753 ++++++++++++++++++ app/lib/draft-eligibility.ts | 195 +++++ app/models/draft-pick.ts | 94 +++ app/models/draft-utils.ts | 134 +++- app/models/participant.ts | 48 +- app/models/season-sport.ts | 25 +- app/routes/api/draft.force-autopick.ts | 120 +-- app/routes/api/draft.force-manual-pick.ts | 29 + app/routes/api/draft.make-pick.ts | 29 + .../leagues/$leagueId.draft.$seasonId.tsx | 253 +++++- ...ague-draft-rules-IMPLEMENTATION-SUMMARY.md | 336 ++++++++ plans/omni-league-draft-rules.md | 424 ++++++++++ 12 files changed, 2290 insertions(+), 150 deletions(-) create mode 100644 app/lib/__tests__/draft-eligibility.test.ts create mode 100644 app/lib/draft-eligibility.ts create mode 100644 plans/omni-league-draft-rules-IMPLEMENTATION-SUMMARY.md create mode 100644 plans/omni-league-draft-rules.md diff --git a/app/lib/__tests__/draft-eligibility.test.ts b/app/lib/__tests__/draft-eligibility.test.ts new file mode 100644 index 0000000..e62b62d --- /dev/null +++ b/app/lib/__tests__/draft-eligibility.test.ts @@ -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"); + }); + }); +}); diff --git a/app/lib/draft-eligibility.ts b/app/lib/draft-eligibility.ts new file mode 100644 index 0000000..4f94774 --- /dev/null +++ b/app/lib/draft-eligibility.ts @@ -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; + ineligibleReasons: Record; // sportId -> reason + flexPicksUsed: number; + flexPicksAvailable: number; + picksBySport: Record; + sportAvailability: Record; +} + +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 = {}; + + 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 = {}; + + 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 = {}; + 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(); + const ineligibleReasons: Record = {}; + + 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"}`; +} diff --git a/app/models/draft-pick.ts b/app/models/draft-pick.ts index 2b9d93c..1a9eb27 100644 --- a/app/models/draft-pick.ts +++ b/app/models/draft-pick.ts @@ -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, + }, + }, + })); +} diff --git a/app/models/draft-utils.ts b/app/models/draft-utils.ts index 1c818a3..fbbe9f3 100644 --- a/app/models/draft-utils.ts +++ b/app/models/draft-utils.ts @@ -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]; - - // Verify participant is not already drafted - const isDrafted = await isParticipantDrafted(seasonId, firstInQueue.participantId); - if (!isDrafted) { - return firstInQueue.participantId; - } - - // If first item was drafted, try next items in queue - for (const item of queue.slice(1)) { + // 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, + }, + }, + }, + }); + + // Try queue items in order, checking both drafted status and eligibility + for (const item of queue) { + const participant = queueParticipants.find((p) => p.id === item.participantId); + if (!participant) continue; + + const sportId = participant.sportsSeason.sport.id; + const isEligible = eligibility.eligibleSportIds.has(sportId); const isDrafted = await isParticipantDrafted(seasonId, item.participantId); - 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 +) { const db = database(); // Get all drafted participant IDs @@ -50,14 +94,42 @@ 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)); - - const sportsSeasonIds = seasonSportsData.map((s: { sportsSeasonId: string }) => s.sportsSeasonId); - + // 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)); + + // 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; } diff --git a/app/models/participant.ts b/app/models/participant.ts index 4970c95..e64e91a 100644 --- a/app/models/participant.ts +++ b/app/models/participant.ts @@ -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; +} diff --git a/app/models/season-sport.ts b/app/models/season-sport.ts index 02577a4..b9f3f25 100644 --- a/app/models/season-sport.ts +++ b/app/models/season-sport.ts @@ -88,7 +88,7 @@ export async function applySportsFromTemplate( templateId: string ): Promise { const db = database(); - + // Get all sports from the template const templateSports = await db.query.seasonTemplateSports.findMany({ where: eq(schema.seasonTemplateSports.templateId, templateId), @@ -106,3 +106,26 @@ export async function applySportsFromTemplate( })) ); } + +/** + * Get sports for a season in a simple format for eligibility calculations + */ +export async function getSeasonSportsSimple(seasonId: string) { + const db = database(); + + const seasonSports = await db.query.seasonSports.findMany({ + where: eq(schema.seasonSports.seasonId, seasonId), + with: { + sportsSeason: { + with: { + sport: true, + }, + }, + }, + }); + + return seasonSports.map((ss) => ({ + id: ss.sportsSeason.sport.id, + name: ss.sportsSeason.sport.name, + })); +} diff --git a/app/routes/api/draft.force-autopick.ts b/app/routes/api/draft.force-autopick.ts index fdaebe4..60f007d 100644 --- a/app/routes/api/draft.force-autopick.ts +++ b/app/routes/api/draft.force-autopick.ts @@ -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; diff --git a/app/routes/api/draft.force-manual-pick.ts b/app/routes/api/draft.force-manual-pick.ts index 32a77e5..179906c 100644 --- a/app/routes/api/draft.force-manual-pick.ts +++ b/app/routes/api/draft.force-manual-pick.ts @@ -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) diff --git a/app/routes/api/draft.make-pick.ts b/app/routes/api/draft.make-pick.ts index de6185d..969d69f 100644 --- a/app/routes/api/draft.make-pick.ts +++ b/app/routes/api/draft.make-pick.ts @@ -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) diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index eb81e7d..3f01b5b 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -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() { )} + + {/* Eligibility Status Banner */} + {eligibility && ( +
+
Draft Status
+
+
+ Flex Picks: + = eligibility.flexPicksAvailable ? "text-red-600 dark:text-red-400 font-semibold" : "text-green-600 dark:text-green-400"}> + {eligibility.flexPicksUsed} / {eligibility.flexPicksAvailable} + +
+ {Object.entries(eligibility.picksBySport).length > 0 && ( + <> +
Picks by Sport:
+ {Object.entries(eligibility.picksBySport).map(([sportId, count]) => { + const sportInfo = eligibility.sportAvailability[sportId]; + return ( +
+ {sportInfo?.sportName || sportId}: + {count} +
+ ); + })} + + )} + {Object.keys(eligibility.ineligibleReasons).length > 0 && ( + <> +
+ Restrictions: +
+ {Object.entries(eligibility.ineligibleReasons).slice(0, 2).map(([sportId, reason]) => { + const sportInfo = eligibility.sportAvailability[sportId]; + return ( +
+ {sportInfo?.sportName}: {reason} +
+ ); + })} + + )} +
+
+ )} {queue.length === 0 ? (

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 (

- + {participant.name} {isDrafted && ( @@ -1032,7 +1181,16 @@ export default function DraftRoom() { Drafted )} - {isInQueue && !isDrafted && ( + {!isDrafted && !isEligible && ( + + Ineligible + + )} + {isInQueue && !isDrafted && isEligible && ( handleMakePick(participant.id) } + disabled={!isEligible} + title={!isEligible ? ineligibleReason : undefined} > Pick @@ -1071,7 +1231,8 @@ export default function DraftRoom() { onClick={() => handleAddToQueue(participant.id) } - title="Add to queue" + title={!isEligible ? ineligibleReason : "Add to queue"} + disabled={!isEligible} > + @@ -1107,6 +1268,8 @@ export default function DraftRoom() { onClick={() => handleAddToQueue(participant.id) } + disabled={!isEligible} + title={!isEligible ? ineligibleReason : undefined} > Add to Queue @@ -1201,32 +1364,60 @@ export default function DraftRoom() { {filteredParticipants .filter((p: any) => !draftedParticipantIds.has(p.id)) - .map((participant: any) => ( - - - {participant.name} - - - {participant.sport.name} - - - {participant.expectedValue} - - - - - - ))} + .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 ( + + +
+ + {participant.name} + + {!isEligible && ( + + Ineligible + + )} +
+ + + {participant.sport.name} + + + {participant.expectedValue} + + + + + + ); + })}
diff --git a/plans/omni-league-draft-rules-IMPLEMENTATION-SUMMARY.md b/plans/omni-league-draft-rules-IMPLEMENTATION-SUMMARY.md new file mode 100644 index 0000000..342de35 --- /dev/null +++ b/plans/omni-league-draft-rules-IMPLEMENTATION-SUMMARY.md @@ -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]; + + +``` + +--- + +## 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 diff --git a/plans/omni-league-draft-rules.md b/plans/omni-league-draft-rules.md new file mode 100644 index 0000000..901d974 --- /dev/null +++ b/plans/omni-league-draft-rules.md @@ -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; + ineligibleReasons: Record; // sportId -> reason + flexPicksUsed: number; + flexPicksAvailable: number; + picksBySport: Record; + sportAvailability: Record; +} + +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