- Added a comprehensive plan for bracket expansion, including support for 4, 8, 16, 32, and 68 team formats. - Introduced a template-based bracket system with predefined templates for NCAA March Madness, NFL Playoffs, NBA Playoffs, and simple brackets. - Updated UI flow for bracket creation, allowing admins to select templates and assign participants flexibly. - Enhanced database schema to accommodate new scoring rules and bracket templates. - Proposed updates to scoring logic to handle non-scoring rounds and participant placements correctly. - Documented implementation phases for gradual rollout of new features. - Addressed critical bugs in the playoff event processing and scoring logic, ensuring proper advancement and scoring rules across multiple leagues.
251 lines
6.7 KiB
TypeScript
251 lines
6.7 KiB
TypeScript
import { eq, and } from "drizzle-orm";
|
|
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
|
|
export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
|
|
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
|
|
|
|
export async function createPlayoffMatch(
|
|
data: NewPlayoffMatch
|
|
): Promise<PlayoffMatch> {
|
|
const db = database();
|
|
const [match] = await db
|
|
.insert(schema.playoffMatches)
|
|
.values(data)
|
|
.returning();
|
|
return match;
|
|
}
|
|
|
|
export async function createManyPlayoffMatches(
|
|
data: NewPlayoffMatch[]
|
|
): Promise<PlayoffMatch[]> {
|
|
const db = database();
|
|
return await db
|
|
.insert(schema.playoffMatches)
|
|
.values(data)
|
|
.returning();
|
|
}
|
|
|
|
export async function findPlayoffMatchById(
|
|
id: string
|
|
): Promise<PlayoffMatch | undefined> {
|
|
const db = database();
|
|
return await db.query.playoffMatches.findFirst({
|
|
where: eq(schema.playoffMatches.id, id),
|
|
with: {
|
|
scoringEvent: true,
|
|
participant1: true,
|
|
participant2: true,
|
|
winner: true,
|
|
loser: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function findPlayoffMatchesByEventId(
|
|
eventId: string
|
|
): Promise<PlayoffMatch[]> {
|
|
const db = database();
|
|
return await db.query.playoffMatches.findMany({
|
|
where: eq(schema.playoffMatches.scoringEventId, eventId),
|
|
orderBy: (matches, { asc }) => [asc(matches.matchNumber)],
|
|
with: {
|
|
participant1: true,
|
|
participant2: true,
|
|
winner: true,
|
|
loser: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function findPlayoffMatchesByEventIdAndRound(
|
|
eventId: string,
|
|
round: string
|
|
): Promise<PlayoffMatch[]> {
|
|
const db = database();
|
|
return await db.query.playoffMatches.findMany({
|
|
where: and(
|
|
eq(schema.playoffMatches.scoringEventId, eventId),
|
|
eq(schema.playoffMatches.round, round)
|
|
),
|
|
orderBy: (matches, { asc }) => [asc(matches.matchNumber)],
|
|
with: {
|
|
participant1: true,
|
|
participant2: true,
|
|
winner: true,
|
|
loser: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function updatePlayoffMatch(
|
|
id: string,
|
|
data: Partial<NewPlayoffMatch>
|
|
): Promise<PlayoffMatch> {
|
|
const db = database();
|
|
const [match] = await db
|
|
.update(schema.playoffMatches)
|
|
.set({ ...data, updatedAt: new Date() })
|
|
.where(eq(schema.playoffMatches.id, id))
|
|
.returning();
|
|
return match;
|
|
}
|
|
|
|
export async function deletePlayoffMatch(id: string): Promise<void> {
|
|
const db = database();
|
|
await db.delete(schema.playoffMatches).where(eq(schema.playoffMatches.id, id));
|
|
}
|
|
|
|
export async function deletePlayoffMatchesByEventId(
|
|
eventId: string
|
|
): Promise<void> {
|
|
const db = database();
|
|
await db
|
|
.delete(schema.playoffMatches)
|
|
.where(eq(schema.playoffMatches.scoringEventId, eventId));
|
|
}
|
|
|
|
/**
|
|
* Set the winner of a playoff match
|
|
*/
|
|
export async function setMatchWinner(
|
|
matchId: string,
|
|
winnerId: string,
|
|
loserId: string,
|
|
participant1Score?: number,
|
|
participant2Score?: number
|
|
): Promise<PlayoffMatch> {
|
|
const db = database();
|
|
const [match] = await db
|
|
.update(schema.playoffMatches)
|
|
.set({
|
|
winnerId,
|
|
loserId,
|
|
isComplete: true,
|
|
participant1Score: participant1Score?.toString(),
|
|
participant2Score: participant2Score?.toString(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(schema.playoffMatches.id, matchId))
|
|
.returning();
|
|
return match;
|
|
}
|
|
|
|
/**
|
|
* Generate a standard single elimination bracket structure
|
|
*/
|
|
export async function generateSingleEliminationBracket(
|
|
eventId: string,
|
|
participantIds: string[]
|
|
): Promise<PlayoffMatch[]> {
|
|
const db = database();
|
|
const numParticipants = participantIds.length;
|
|
|
|
// Validate that we have 4 or 8 participants for standard bracket
|
|
if (numParticipants !== 4 && numParticipants !== 8) {
|
|
throw new Error("Single elimination bracket requires 4 or 8 participants");
|
|
}
|
|
|
|
const matches: NewPlayoffMatch[] = [];
|
|
|
|
if (numParticipants === 8) {
|
|
// Quarterfinals (4 matches)
|
|
for (let i = 0; i < 4; i++) {
|
|
matches.push({
|
|
scoringEventId: eventId,
|
|
round: "Quarterfinals",
|
|
matchNumber: i + 1,
|
|
participant1Id: participantIds[i * 2],
|
|
participant2Id: participantIds[i * 2 + 1],
|
|
isComplete: false,
|
|
});
|
|
}
|
|
|
|
// Semifinals (2 matches)
|
|
for (let i = 0; i < 2; i++) {
|
|
matches.push({
|
|
scoringEventId: eventId,
|
|
round: "Semifinals",
|
|
matchNumber: i + 1,
|
|
participant1Id: null,
|
|
participant2Id: null,
|
|
isComplete: false,
|
|
});
|
|
}
|
|
} else if (numParticipants === 4) {
|
|
// Semifinals (2 matches)
|
|
for (let i = 0; i < 2; i++) {
|
|
matches.push({
|
|
scoringEventId: eventId,
|
|
round: "Semifinals",
|
|
matchNumber: i + 1,
|
|
participant1Id: participantIds[i * 2],
|
|
participant2Id: participantIds[i * 2 + 1],
|
|
isComplete: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Finals (1 match)
|
|
matches.push({
|
|
scoringEventId: eventId,
|
|
round: "Finals",
|
|
matchNumber: 1,
|
|
participant1Id: null,
|
|
participant2Id: null,
|
|
isComplete: false,
|
|
});
|
|
|
|
return await createManyPlayoffMatches(matches);
|
|
}
|
|
|
|
/**
|
|
* Advance the winner of a match to the next round
|
|
* Uses deterministic slot assignment to maintain bracket structure
|
|
*/
|
|
export async function advanceWinner(
|
|
matchId: string,
|
|
winnerId: string
|
|
): Promise<void> {
|
|
const db = database();
|
|
|
|
// Get the match
|
|
const match = await findPlayoffMatchById(matchId);
|
|
if (!match) throw new Error("Match not found");
|
|
|
|
// Determine which round this is and where to advance
|
|
const eventId = match.scoringEventId;
|
|
let nextRound: string;
|
|
let nextMatchNumber: number;
|
|
let participantSlot: 'participant1Id' | 'participant2Id';
|
|
|
|
if (match.round === "Quarterfinals") {
|
|
nextRound = "Semifinals";
|
|
// Match 1,2 -> SF1, Match 3,4 -> SF2
|
|
nextMatchNumber = match.matchNumber <= 2 ? 1 : 2;
|
|
// Odd matches (1, 3) -> participant1, Even matches (2, 4) -> participant2
|
|
participantSlot = match.matchNumber % 2 === 1 ? 'participant1Id' : 'participant2Id';
|
|
} else if (match.round === "Semifinals") {
|
|
nextRound = "Finals";
|
|
nextMatchNumber = 1;
|
|
// SF Match 1 -> Finals participant1, SF Match 2 -> Finals participant2
|
|
participantSlot = match.matchNumber === 1 ? 'participant1Id' : 'participant2Id';
|
|
} else {
|
|
// Finals - no advancement needed
|
|
return;
|
|
}
|
|
|
|
// Find the next match
|
|
const nextMatches = await findPlayoffMatchesByEventIdAndRound(eventId, nextRound);
|
|
const nextMatch = nextMatches.find(m => m.matchNumber === nextMatchNumber);
|
|
|
|
if (!nextMatch) throw new Error("Next match not found");
|
|
|
|
// Check if the slot is already filled
|
|
if (nextMatch[participantSlot]) {
|
|
throw new Error(`Next match ${participantSlot} is already filled`);
|
|
}
|
|
|
|
// Fill the determined slot
|
|
await updatePlayoffMatch(nextMatch.id, { [participantSlot]: winnerId });
|
|
}
|