feat: proactively prune ineligible queue items after each pick (#59)

After every pick, recalculate draft eligibility for all teams and
remove any queued participants whose sport is no longer eligible
(e.g. a team queued a snooker player but just filled their last flex
slot). Previously this was only caught lazily when autodraft fired,
which could pause the draft or pick an unwanted player.

- Add getAllQueuesForSeason to draft-queue.ts — fetches all queue rows
  for a season in one query (Map<teamId, QueueItem[]>) instead of N+1
  per-team queries
- Add pruneIneligibleQueueItems to draft-utils.ts — uses Promise.all
  for the four required data fetches, collects ineligible items in a
  single loop pass, warns on orphaned participant references
- Call from both pick paths: executeAutoPick and draft.make-pick.ts
- Emit queue-eligibility-pruned socket event per affected team so the
  client updates the queue UI in real time
- Add 5 tests covering: single ineligible removal, all eligible (no-op),
  empty queues (no delete called, getTeamQueue never called), mixed
  queue (only ineligible item removed), and unknown participant (warn)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-03-02 22:07:22 -08:00 committed by GitHub
parent 09918f4b38
commit 16aa450d63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 441 additions and 4 deletions

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { autoPickForTeam } from "../draft-utils";
import { autoPickForTeam, pruneIneligibleQueueItems } from "../draft-utils";
// Mock all external model dependencies so we can control their return values
vi.mock("~/models/draft-pick", () => ({
@ -18,6 +18,7 @@ vi.mock("~/models/season-sport", () => ({
vi.mock("~/models/draft-queue", () => ({
getTeamQueue: vi.fn(),
getAllQueuesForSeason: vi.fn(),
}));
vi.mock("~/lib/draft-eligibility", () => ({
@ -35,7 +36,7 @@ import {
} from "~/models/draft-pick";
import { getParticipantsForSeasonWithSports } from "~/models/participant";
import { getSeasonSportsSimple } from "~/models/season-sport";
import { getTeamQueue } from "~/models/draft-queue";
import { getTeamQueue, getAllQueuesForSeason } from "~/models/draft-queue";
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
const SEASON_ID = "season-1";
@ -217,6 +218,108 @@ describe("autoPickForTeam queueOnly constraint (AC2 & AC3)", () => {
});
});
describe("ineligible sport — only queued player's sport is full", () => {
it("removes the ineligible queue item and returns null when queueOnly=true", async () => {
// Scenario: team has a snooker player queued but snooker is already full (not in eligibleSportIds)
const queue = [{ id: "q-snooker", participantId: "p-snooker", queuePosition: 1 }];
vi.mocked(getTeamQueue).mockResolvedValue(queue as any);
// Snooker is NOT eligible — only "sport-other" is
vi.mocked(calculateDraftEligibility).mockReturnValue({
eligibleSportIds: new Set(["sport-other"]),
} as ReturnType<typeof calculateDraftEligibility>);
const mockDelete = vi.fn().mockReturnThis();
const mockWhere = vi.fn().mockResolvedValue(undefined);
const mockDb = makeMockDb({
query: {
participants: {
findMany: vi.fn().mockResolvedValue([
{
id: "p-snooker",
name: "Ronnie O'Sullivan",
sportsSeason: { sport: { id: "sport-snooker", name: "Snooker" } },
},
]),
},
},
delete: mockDelete,
where: mockWhere,
});
vi.mocked(isParticipantDrafted).mockResolvedValue(false); // not drafted — ineligible due to sport
const result = await autoPickForTeam(
SEASON_ID,
TEAM_ID,
DRAFT_ROUNDS,
ALL_TEAM_IDS,
mockDb as any,
true // queueOnly — no EV fallback
);
// Queue item should be removed
expect(mockDelete).toHaveBeenCalled();
// No valid pick available
expect(result).toBeNull();
});
it("falls back to EV pick from eligible sports when queueOnly=false", async () => {
// Scenario: same ineligible snooker player, but queueOnly is off so system picks best available
const queue = [{ id: "q-snooker", participantId: "p-snooker", queuePosition: 1 }];
vi.mocked(getTeamQueue).mockResolvedValue(queue as any);
vi.mocked(calculateDraftEligibility).mockReturnValue({
eligibleSportIds: new Set(["sport-other"]),
} as ReturnType<typeof calculateDraftEligibility>);
// where() call order:
// 1. delete().where() — remove ineligible snooker item from queue
// 2. select().from().where() — getTopAvailableParticipant: drafted picks → []
// 3. select().from().innerJoin().innerJoin().where() — season sports → [{...}]
// 4. select().from(participants).where().orderBy() — participant query (chain)
const mockDb = {
query: {
participants: {
findMany: vi.fn().mockResolvedValue([
{
id: "p-snooker",
name: "Ronnie O'Sullivan",
sportsSeason: { sport: { id: "sport-snooker", name: "Snooker" } },
},
]),
},
},
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
where: vi.fn()
.mockResolvedValueOnce(undefined) // call 1: delete ineligible queue items
.mockResolvedValueOnce([]) // call 2: drafted picks
.mockResolvedValueOnce([{ sportsSeasonId: "ss-other", sportId: "sport-other" }]) // call 3: season sports
.mockReturnThis(), // call 4: participant query → chain to orderBy
orderBy: vi.fn().mockResolvedValue([
{ id: "p-ev", name: "EV Player", expectedValue: "90.00" },
]),
delete: vi.fn().mockReturnThis(),
};
vi.mocked(isParticipantDrafted).mockResolvedValue(false);
const result = await autoPickForTeam(
SEASON_ID,
TEAM_ID,
DRAFT_ROUNDS,
ALL_TEAM_IDS,
mockDb as any,
false // queueOnly off — should fall back to best available
);
// Should pick the best available from eligible sports, not the snooker player
expect(result).toBe("p-ev");
});
});
describe("partial queue — first item drafted, second available", () => {
it("skips drafted item and returns the next valid participant", async () => {
const queue = [
@ -302,3 +405,187 @@ describe("autoPickForTeam queueOnly constraint (AC2 & AC3)", () => {
});
});
});
describe("pruneIneligibleQueueItems", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getDraftPicksWithSports).mockResolvedValue([]);
vi.mocked(getTeamDraftPicksWithSports).mockResolvedValue([]);
vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([]);
vi.mocked(getSeasonSportsSimple).mockResolvedValue([]);
});
// Helper to build the Map<teamId, queue[]> returned by getAllQueuesForSeason
function makeSeasonQueues(entries: [string, { id: string; participantId: string; queuePosition: number }[]][]) {
return new Map(entries.map(([teamId, items]) => [teamId, items as any]));
}
it("removes queued snooker player when snooker is no longer eligible for a team", async () => {
vi.mocked(getDraftPicksWithSports).mockResolvedValue([]);
vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([
{ id: "p-snooker", name: "Ronnie O'Sullivan", sport: { id: "sport-snooker", name: "Snooker" } },
] as any);
vi.mocked(getSeasonSportsSimple).mockResolvedValue([]);
// team-1: snooker ineligible; team-2: snooker still eligible
vi.mocked(calculateDraftEligibility)
.mockReturnValueOnce({ eligibleSportIds: new Set([]) } as any)
.mockReturnValueOnce({ eligibleSportIds: new Set(["sport-snooker"]) } as any);
vi.mocked(getAllQueuesForSeason).mockResolvedValue(
makeSeasonQueues([
[TEAM_ID, [{ id: "q-1", participantId: "p-snooker", queuePosition: 1 }]],
["team-2", []],
])
);
const mockDelete = vi.fn().mockReturnThis();
const mockWhere = vi.fn().mockResolvedValue(undefined);
const mockDb = makeMockDb({ delete: mockDelete, where: mockWhere });
const result = await pruneIneligibleQueueItems({
seasonId: SEASON_ID,
draftRounds: DRAFT_ROUNDS,
allTeamIds: ALL_TEAM_IDS,
db: mockDb as any,
});
expect(result).toHaveLength(1);
expect(result[0].teamId).toBe(TEAM_ID);
expect(result[0].removedParticipantIds).toEqual(["p-snooker"]);
expect(mockDelete).toHaveBeenCalledTimes(1);
});
it("returns empty array when all teams still have their sports eligible", async () => {
vi.mocked(getDraftPicksWithSports).mockResolvedValue([]);
vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([
{ id: "p-snooker", name: "Ronnie O'Sullivan", sport: { id: "sport-snooker", name: "Snooker" } },
] as any);
vi.mocked(getSeasonSportsSimple).mockResolvedValue([]);
vi.mocked(calculateDraftEligibility).mockReturnValue(
{ eligibleSportIds: new Set(["sport-snooker"]) } as any
);
vi.mocked(getAllQueuesForSeason).mockResolvedValue(
makeSeasonQueues([
[TEAM_ID, [{ id: "q-1", participantId: "p-snooker", queuePosition: 1 }]],
["team-2", [{ id: "q-2", participantId: "p-snooker", queuePosition: 1 }]],
])
);
const mockDelete = vi.fn().mockReturnThis();
const mockDb = makeMockDb({ delete: mockDelete });
const result = await pruneIneligibleQueueItems({
seasonId: SEASON_ID,
draftRounds: DRAFT_ROUNDS,
allTeamIds: ALL_TEAM_IDS,
db: mockDb as any,
});
expect(result).toHaveLength(0);
expect(mockDelete).not.toHaveBeenCalled();
});
it("skips teams with empty queues and does not call delete", async () => {
vi.mocked(getDraftPicksWithSports).mockResolvedValue([]);
vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([]);
vi.mocked(getSeasonSportsSimple).mockResolvedValue([]);
vi.mocked(calculateDraftEligibility).mockReturnValue(
{ eligibleSportIds: new Set([]) } as any
);
// Both teams return no queue items from the single bulk fetch
vi.mocked(getAllQueuesForSeason).mockResolvedValue(new Map());
const mockDelete = vi.fn().mockReturnThis();
const mockDb = makeMockDb({ delete: mockDelete });
const result = await pruneIneligibleQueueItems({
seasonId: SEASON_ID,
draftRounds: DRAFT_ROUNDS,
allTeamIds: ALL_TEAM_IDS,
db: mockDb as any,
});
expect(result).toHaveLength(0);
expect(mockDelete).not.toHaveBeenCalled();
// The bulk fetch replaces per-team queries — getTeamQueue should never be called
expect(vi.mocked(getTeamQueue)).not.toHaveBeenCalled();
});
it("prunes only the ineligible items when a team has a mixed queue", async () => {
// Team has two queued players: snooker (ineligible) and NFL (still eligible)
vi.mocked(getDraftPicksWithSports).mockResolvedValue([]);
vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([
{ id: "p-snooker", name: "Ronnie O'Sullivan", sport: { id: "sport-snooker", name: "Snooker" } },
{ id: "p-nfl", name: "Patrick Mahomes", sport: { id: "sport-nfl", name: "NFL" } },
] as any);
vi.mocked(getSeasonSportsSimple).mockResolvedValue([]);
vi.mocked(calculateDraftEligibility).mockReturnValue(
{ eligibleSportIds: new Set(["sport-nfl"]) } as any // snooker full, NFL still ok
);
vi.mocked(getAllQueuesForSeason).mockResolvedValue(
makeSeasonQueues([
[TEAM_ID, [
{ id: "q-snooker", participantId: "p-snooker", queuePosition: 1 },
{ id: "q-nfl", participantId: "p-nfl", queuePosition: 2 },
]],
])
);
const mockDelete = vi.fn().mockReturnThis();
const mockWhere = vi.fn().mockResolvedValue(undefined);
const mockDb = makeMockDb({ delete: mockDelete, where: mockWhere });
const result = await pruneIneligibleQueueItems({
seasonId: SEASON_ID,
draftRounds: DRAFT_ROUNDS,
allTeamIds: [TEAM_ID],
db: mockDb as any,
});
expect(result).toHaveLength(1);
expect(result[0].removedParticipantIds).toEqual(["p-snooker"]);
// The NFL player must NOT be removed
expect(result[0].removedParticipantIds).not.toContain("p-nfl");
expect(mockDelete).toHaveBeenCalledTimes(1);
});
it("skips a queue item whose participant is not found in season sports and logs a warning", async () => {
vi.mocked(getDraftPicksWithSports).mockResolvedValue([]);
// allParticipants does NOT include p-mystery
vi.mocked(getParticipantsForSeasonWithSports).mockResolvedValue([]);
vi.mocked(getSeasonSportsSimple).mockResolvedValue([]);
vi.mocked(calculateDraftEligibility).mockReturnValue(
{ eligibleSportIds: new Set([]) } as any
);
vi.mocked(getAllQueuesForSeason).mockResolvedValue(
makeSeasonQueues([
[TEAM_ID, [{ id: "q-mystery", participantId: "p-mystery", queuePosition: 1 }]],
])
);
const mockDelete = vi.fn().mockReturnThis();
const mockDb = makeMockDb({ delete: mockDelete });
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const result = await pruneIneligibleQueueItems({
seasonId: SEASON_ID,
draftRounds: DRAFT_ROUNDS,
allTeamIds: [TEAM_ID],
db: mockDb as any,
});
// Item is skipped — not deleted, not returned
expect(result).toHaveLength(0);
expect(mockDelete).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("p-mystery"));
warnSpy.mockRestore();
});
});

View file

@ -86,6 +86,29 @@ export async function removeParticipantFromQueue(teamId: string, participantId:
);
}
export async function getAllQueuesForSeason(
seasonId: string,
providedDb?: ReturnType<typeof database>
): Promise<Map<string, typeof schema.draftQueue.$inferSelect[]>> {
const db = providedDb || database();
const rows = await db
.select()
.from(schema.draftQueue)
.where(eq(schema.draftQueue.seasonId, seasonId))
.orderBy(asc(schema.draftQueue.queuePosition));
const byTeam = new Map<string, typeof schema.draftQueue.$inferSelect[]>();
for (const row of rows) {
const existing = byTeam.get(row.teamId);
if (existing) {
existing.push(row);
} else {
byTeam.set(row.teamId, [row]);
}
}
return byTeam;
}
export async function clearAllQueuesForSeason(seasonId: string) {
const db = database();
await db

View file

@ -2,7 +2,7 @@ import { database } from "~/database/context";
import * as schema from "~/database/schema";
import { eq, and, notInArray, desc, inArray, sql, asc } from "drizzle-orm";
import type { InferSelectModel } from "drizzle-orm";
import { getTeamQueue } from "./draft-queue";
import { getTeamQueue, getAllQueuesForSeason } from "./draft-queue";
import { isParticipantDrafted, getDraftPicksWithSports, getTeamDraftPicksWithSports } from "./draft-pick";
import { getParticipantsForSeasonWithSports } from "./participant";
import { getSeasonSportsSimple } from "./season-sport";
@ -354,6 +354,85 @@ export function getTeamForPick(
return sortedOrder[teamIndex]?.teamId || null;
}
/**
* After a pick is committed, recalculate draft eligibility for every team and remove
* any queued participants whose sport is no longer eligible for that team.
*
* This handles cases like: a team has a snooker player queued but has now filled their
* last flex slot that snooker could have used the pick should be proactively removed
* rather than silently failing or pausing the draft later.
*
* Returns the per-team removals so the caller can emit socket events.
*/
export async function pruneIneligibleQueueItems(params: {
seasonId: string;
draftRounds: number;
allTeamIds: string[];
db: ReturnType<typeof database>;
}): Promise<{ teamId: string; removedParticipantIds: string[] }[]> {
const { seasonId, draftRounds, allTeamIds, db } = params;
const [allPicks, allParticipants, seasonSports, allQueues] = await Promise.all([
getDraftPicksWithSports(seasonId, db),
getParticipantsForSeasonWithSports(seasonId, db),
getSeasonSportsSimple(seasonId, db),
getAllQueuesForSeason(seasonId, db),
]);
// Build a fast lookup: participantId → sportId
const participantSportMap = new Map<string, string>();
for (const p of allParticipants) {
participantSportMap.set(p.id, p.sport.id);
}
const allTeams = allTeamIds.map((id) => ({ id }));
const results: { teamId: string; removedParticipantIds: string[] }[] = [];
for (const teamId of allTeamIds) {
const queue = allQueues.get(teamId) ?? [];
if (queue.length === 0) continue;
const teamPicks = allPicks.filter((p) => p.teamId === teamId);
const eligibility = calculateDraftEligibility(
teamId,
teamPicks,
allPicks,
allParticipants,
seasonSports,
draftRounds,
allTeams
);
const ineligible: { id: string; participantId: string }[] = [];
for (const item of queue) {
const sportId = participantSportMap.get(item.participantId);
if (sportId === undefined) {
console.warn(
`[QueuePrune] Team ${teamId}: queue item ${item.id} references participant ${item.participantId} not found in season sports — skipping`
);
continue;
}
if (!eligibility.eligibleSportIds.has(sportId)) {
ineligible.push({ id: item.id, participantId: item.participantId });
}
}
if (ineligible.length > 0) {
await db
.delete(schema.draftQueue)
.where(inArray(schema.draftQueue.id, ineligible.map((i) => i.id)));
const removedParticipantIds = ineligible.map((i) => i.participantId);
results.push({ teamId, removedParticipantIds });
console.log(
`[QueuePrune] Team ${teamId}: removed ${ineligible.length} ineligible items (sport no longer eligible)`
);
}
}
return results;
}
/**
* Execute an autopick for a team - unified function for both commissioner-forced and timer-based autopicks
*
@ -618,6 +697,26 @@ export async function executeAutoPick(params: {
)
);
// Proactively prune queue items that are now ineligible due to this pick
// (e.g. a team queued a snooker player but just filled their last flex slot)
try {
const prunedQueues = await pruneIneligibleQueueItems({
seasonId,
draftRounds: season.draftRounds,
allTeamIds,
db,
});
const io = getSocketIO();
for (const { teamId: prunedTeamId, removedParticipantIds } of prunedQueues) {
io.to(`draft-${seasonId}`).emit("queue-eligibility-pruned", {
teamId: prunedTeamId,
removedParticipantIds,
});
}
} catch (error) {
console.error("[AutoPick] Error pruning ineligible queue items:", error);
}
// Handle autodraft settings for timer-based picks with "next_pick" mode
if (triggeredBy === "timer" && autodraftSettings?.isEnabled && autodraftSettings.mode === "next_pick") {
console.log(`[AutoPick] Disabling autodraft for team ${teamId} after next_pick`);

View file

@ -6,7 +6,7 @@ import { calculateDraftEligibility } from "~/lib/draft-eligibility";
import { getDraftPicksWithSports, getTeamDraftPicksWithSports } from "~/models/draft-pick";
import { getParticipantsForSeasonWithSports } from "~/models/participant";
import { getSeasonSportsSimple } from "~/models/season-sport";
import { calculatePickInfo, checkAndTriggerNextAutodraft } from "~/models/draft-utils";
import { calculatePickInfo, checkAndTriggerNextAutodraft, pruneIneligibleQueueItems } from "~/models/draft-utils";
import { getSocketIO } from "../../../server/socket";
import type { ActionFunctionArgs } from "react-router";
@ -173,6 +173,26 @@ export async function action(args: ActionFunctionArgs) {
console.error("Socket.IO participant-removed-from-queues error:", error);
}
// Proactively prune queue items that are now ineligible due to this pick
// (e.g. a team queued a snooker player but just filled their last flex slot)
try {
const allTeamIds = draftSlots.map((slot) => slot.teamId);
const prunedQueues = await pruneIneligibleQueueItems({
seasonId,
draftRounds: season.draftRounds,
allTeamIds,
db,
});
for (const { teamId: prunedTeamId, removedParticipantIds } of prunedQueues) {
getSocketIO().to(`draft-${seasonId}`).emit("queue-eligibility-pruned", {
teamId: prunedTeamId,
removedParticipantIds,
});
}
} catch (error) {
console.error("Queue pruning error after pick:", error);
}
// Calculate next pick info (before updating season)
const nextPickNumber = currentPickNumber + 1;
const totalPicks = totalTeams * season.draftRounds;

View file

@ -787,6 +787,12 @@ export default function DraftRoom() {
);
};
const handleQueueEligibilityPruned = (data: { teamId: string; removedParticipantIds: string[] }) => {
if (data.teamId !== userTeam?.id) return;
const removed = new Set(data.removedParticipantIds);
setQueue((prev: any) => prev.filter((item: any) => !removed.has(item.participantId)));
};
const handlePickReplaced = (data: any) => {
setPicks((prev: any) =>
prev.map((p: any) => (p.pickNumber === data.pickNumber ? data.pick : p))
@ -839,6 +845,7 @@ export default function DraftRoom() {
on("team-disconnected", handleTeamDisconnected);
on("connected-teams-list", handleConnectedTeamsList);
on("participant-removed-from-queues", handleParticipantRemovedFromQueues);
on("queue-eligibility-pruned", handleQueueEligibilityPruned);
on("pick-replaced", handlePickReplaced);
on("draft-rolled-back", handleDraftRolledBack);
on("draft-state-sync", handleDraftStateSync);
@ -854,6 +861,7 @@ export default function DraftRoom() {
off("team-disconnected", handleTeamDisconnected);
off("connected-teams-list", handleConnectedTeamsList);
off("participant-removed-from-queues", handleParticipantRemovedFromQueues);
off("queue-eligibility-pruned", handleQueueEligibilityPruned);
off("pick-replaced", handlePickReplaced);
off("draft-rolled-back", handleDraftRolledBack);
off("draft-state-sync", handleDraftStateSync);