Closes #72 Upgrades ineligibleReasons from Record<string, string> to a structured {code, message} type so the UI can render the human-readable reason inline below the Ineligible badge in both AvailableParticipantsSection and ParticipantSelectionDialog. API routes surface the message in 400 responses unchanged. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
233 lines
6.4 KiB
TypeScript
233 lines
6.4 KiB
TypeScript
/**
|
|
* 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 type DraftIneligibilityCode =
|
|
| "no_flex_spots"
|
|
| "no_extra_participants"
|
|
| "unavailable";
|
|
|
|
export interface DraftIneligibilityReason {
|
|
code: DraftIneligibilityCode;
|
|
message: string;
|
|
}
|
|
|
|
export interface DraftEligibility {
|
|
teamId: string;
|
|
eligibleSportIds: Set<string>;
|
|
ineligibleReasons: Record<string, DraftIneligibilityReason>; // 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;
|
|
}
|
|
|
|
function getFlexSpotsMessage(
|
|
flexPicksUsed: number,
|
|
flexPicksAvailable: number
|
|
): DraftIneligibilityReason {
|
|
return {
|
|
code: "no_flex_spots",
|
|
message: `Out of flex spots (${flexPicksUsed}/${flexPicksAvailable})`,
|
|
};
|
|
}
|
|
|
|
function getNoExtrasMessage(sportName: string): DraftIneligibilityReason {
|
|
return {
|
|
code: "no_extra_participants",
|
|
message: `Not enough ${sportName} participants for remaining teams`,
|
|
};
|
|
}
|
|
|
|
function getUnavailableMessage(
|
|
sportName: string,
|
|
undraftedCount: number,
|
|
teamsStillNeeding: number
|
|
): DraftIneligibilityReason {
|
|
return {
|
|
code: "unavailable",
|
|
message: `Only ${undraftedCount} ${sportName} participant${undraftedCount === 1 ? "" : "s"} left for ${teamsStillNeeding} team${teamsStillNeeding === 1 ? "" : "s"}`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculate which sports a team is eligible to draft from based on:
|
|
* - 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, DraftIneligibilityReason> = {};
|
|
|
|
for (const sport of seasonSports) {
|
|
const sportId = sport.id;
|
|
const availability = sportAvailability[sportId];
|
|
const currentTeamHasSport = (picksBySport[sportId] || 0) > 0;
|
|
|
|
let isEligible = false;
|
|
let reason: DraftIneligibilityReason | null = null;
|
|
|
|
if (!currentTeamHasSport) {
|
|
// Team hasn't drafted from this sport yet (would be first pick)
|
|
if (availability.canDraftAsFirst) {
|
|
isEligible = true;
|
|
} else {
|
|
reason = getUnavailableMessage(sport.name, availability.undraftedCount, availability.teamsStillNeeding);
|
|
}
|
|
} else {
|
|
// Team has drafted from this sport (would be flex pick)
|
|
if (!hasFlexesRemaining) {
|
|
reason = getFlexSpotsMessage(flexPicksUsed, flexPicksAvailable);
|
|
} else if (!availability.canDraftAsFlex) {
|
|
reason = getNoExtrasMessage(sport.name);
|
|
} else {
|
|
isEligible = true;
|
|
}
|
|
}
|
|
|
|
if (isEligible) {
|
|
eligibleSportIds.add(sportId);
|
|
} else if (reason) {
|
|
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"}`;
|
|
}
|