- 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.
255 lines
8.1 KiB
TypeScript
255 lines
8.1 KiB
TypeScript
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
|
|
import { redirect } from "react-router";
|
|
import { findSportsSeasonById } from "~/models/sports-season";
|
|
import { findParticipantsBySportsSeasonId } from "~/models/participant";
|
|
import { getScoringEventById } from "~/models/scoring-event";
|
|
import {
|
|
findPlayoffMatchesByEventId,
|
|
generateSingleEliminationBracket,
|
|
setMatchWinner,
|
|
advanceWinner,
|
|
findPlayoffMatchById,
|
|
} from "~/models/playoff-match";
|
|
import { processPlayoffEvent } from "~/models/scoring-calculator";
|
|
|
|
export async function loader({ params }: Route.LoaderArgs) {
|
|
const sportsSeason = await findSportsSeasonById(params.id);
|
|
|
|
if (!sportsSeason) {
|
|
throw new Response("Sports season not found", { status: 404 });
|
|
}
|
|
|
|
const event = await getScoringEventById(params.eventId);
|
|
|
|
if (!event) {
|
|
throw new Response("Event not found", { status: 404 });
|
|
}
|
|
|
|
if (event.eventType !== "playoff_game") {
|
|
throw new Response("This event is not a playoff event", { status: 400 });
|
|
}
|
|
|
|
const participants = await findParticipantsBySportsSeasonId(params.id);
|
|
const matches = await findPlayoffMatchesByEventId(params.eventId);
|
|
|
|
// The matches already include participant relations from the model query
|
|
return {
|
|
sportsSeason: sportsSeason as typeof sportsSeason & {
|
|
sport: { id: string; name: string; type: string; slug: string };
|
|
},
|
|
event,
|
|
participants,
|
|
matches: matches as Array<typeof matches[0] & {
|
|
participant1: { id: string; name: string } | null;
|
|
participant2: { id: string; name: string } | null;
|
|
winner: { id: string; name: string } | null;
|
|
loser: { id: string; name: string } | null;
|
|
}>,
|
|
};
|
|
}
|
|
|
|
export async function action({ request, params }: Route.ActionArgs) {
|
|
const formData = await request.formData();
|
|
const intent = formData.get("intent");
|
|
|
|
if (intent === "generate-bracket") {
|
|
const bracketSize = formData.get("bracketSize");
|
|
|
|
if (bracketSize !== "4" && bracketSize !== "8") {
|
|
return { error: "Invalid bracket size" };
|
|
}
|
|
|
|
const size = parseInt(bracketSize, 10);
|
|
const participantIds: string[] = [];
|
|
|
|
for (let i = 0; i < size; i++) {
|
|
const participantId = formData.get(`participant${i}`);
|
|
if (typeof participantId !== "string" || !participantId) {
|
|
return { error: `Participant ${i + 1} is required` };
|
|
}
|
|
participantIds.push(participantId);
|
|
}
|
|
|
|
// Check for duplicates
|
|
const uniqueParticipants = new Set(participantIds);
|
|
if (uniqueParticipants.size !== participantIds.length) {
|
|
return { error: "Each participant can only be selected once" };
|
|
}
|
|
|
|
try {
|
|
await generateSingleEliminationBracket(params.eventId, participantIds);
|
|
return { success: "Bracket generated successfully" };
|
|
} catch (error) {
|
|
console.error("Error generating bracket:", error);
|
|
return {
|
|
error:
|
|
error instanceof Error
|
|
? error.message
|
|
: "Failed to generate bracket",
|
|
};
|
|
}
|
|
}
|
|
|
|
if (intent === "set-winner") {
|
|
const matchId = formData.get("matchId");
|
|
const winnerId = formData.get("winnerId");
|
|
|
|
if (typeof matchId !== "string" || !matchId) {
|
|
return { error: "Match ID is required" };
|
|
}
|
|
|
|
if (typeof winnerId !== "string" || !winnerId) {
|
|
return { error: "Winner ID is required" };
|
|
}
|
|
|
|
try {
|
|
const match = await findPlayoffMatchById(matchId);
|
|
if (!match) {
|
|
return { error: "Match not found" };
|
|
}
|
|
|
|
// Determine loser
|
|
const loserId =
|
|
match.participant1Id === winnerId
|
|
? match.participant2Id
|
|
: match.participant1Id;
|
|
|
|
if (!loserId) {
|
|
return { error: "Could not determine loser" };
|
|
}
|
|
|
|
// Set the winner
|
|
await setMatchWinner(matchId, winnerId, loserId);
|
|
|
|
// Try to advance the winner to the next round (if not Finals)
|
|
if (match.round !== "Finals") {
|
|
try {
|
|
await advanceWinner(matchId, winnerId);
|
|
} catch (error) {
|
|
// Only ignore "already filled" errors, re-throw unexpected errors
|
|
if (
|
|
error instanceof Error &&
|
|
error.message.includes("already filled")
|
|
) {
|
|
console.warn("Match already filled, skipping advancement");
|
|
} else {
|
|
throw error; // Re-throw unexpected errors
|
|
}
|
|
}
|
|
}
|
|
|
|
return { success: "Winner set successfully" };
|
|
} catch (error) {
|
|
console.error("Error setting winner:", error);
|
|
return {
|
|
error:
|
|
error instanceof Error ? error.message : "Failed to set winner",
|
|
};
|
|
}
|
|
}
|
|
|
|
if (intent === "complete-round") {
|
|
const round = formData.get("round");
|
|
|
|
if (
|
|
round !== "Quarterfinals" &&
|
|
round !== "Semifinals" &&
|
|
round !== "Finals"
|
|
) {
|
|
return { error: "Invalid round" };
|
|
}
|
|
|
|
try {
|
|
// Get the event and update playoffRound to the completed round
|
|
const event = await getScoringEventById(params.eventId);
|
|
if (!event) {
|
|
return { error: "Event not found" };
|
|
}
|
|
|
|
// Verify all matches in this round are complete
|
|
const matches = await findPlayoffMatchesByEventId(params.eventId);
|
|
const roundMatches = matches.filter((m) => m.round === round);
|
|
|
|
const allComplete = roundMatches.every((m) => m.isComplete);
|
|
if (!allComplete) {
|
|
return { error: `Not all matches in ${round} are complete` };
|
|
}
|
|
|
|
// Validate round order: ensure previous rounds are complete
|
|
const { findParticipantResultsBySportsSeasonId } = await import(
|
|
"~/models/participant-result"
|
|
);
|
|
const existingResults = await findParticipantResultsBySportsSeasonId(
|
|
params.id
|
|
);
|
|
|
|
if (round === "Finals") {
|
|
// Finals requires Semifinals to be complete (3rd/4th place results exist)
|
|
const hasSemifinalResults = existingResults.some(
|
|
(r) => r.finalPosition === 3 || r.finalPosition === 4
|
|
);
|
|
if (!hasSemifinalResults) {
|
|
return {
|
|
error:
|
|
"Semifinals must be completed before Finals (no 3rd/4th place results found)",
|
|
};
|
|
}
|
|
} else if (round === "Semifinals") {
|
|
// Semifinals requires Quarterfinals to be complete if QF matches exist
|
|
const hasQuarterfinals = matches.some((m) => m.round === "Quarterfinals");
|
|
if (hasQuarterfinals) {
|
|
const hasQuarterfinalsResults = existingResults.some(
|
|
(r) =>
|
|
r.finalPosition === 5 ||
|
|
r.finalPosition === 6 ||
|
|
r.finalPosition === 7 ||
|
|
r.finalPosition === 8
|
|
);
|
|
if (!hasQuarterfinalsResults) {
|
|
return {
|
|
error:
|
|
"Quarterfinals must be completed before Semifinals (no 5th-8th place results found)",
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get the sports season to find a fantasy season for scoring rules
|
|
const sportsSeason = await findSportsSeasonById(params.id);
|
|
if (!sportsSeason) {
|
|
return { error: "Sports season not found" };
|
|
}
|
|
|
|
// Process the playoff event to calculate placements
|
|
// We need to temporarily update the event's playoffRound to the one being completed
|
|
const { updateScoringEvent } = await import("~/models/scoring-event");
|
|
await updateScoringEvent(params.eventId, { playoffRound: round });
|
|
|
|
// Get a fantasy season ID for scoring calculation
|
|
const { findSeasonSportsBySportsSeasonId } = await import(
|
|
"~/models/season-sport"
|
|
);
|
|
const seasonSports = await findSeasonSportsBySportsSeasonId(params.id);
|
|
if (seasonSports.length === 0) {
|
|
return {
|
|
error: "No fantasy seasons found for this sports season",
|
|
};
|
|
}
|
|
|
|
// Process playoff event to update participant results
|
|
await processPlayoffEvent(params.eventId);
|
|
|
|
return {
|
|
success: `${round} completed and placements calculated successfully`,
|
|
};
|
|
} catch (error) {
|
|
console.error("Error completing round:", error);
|
|
return {
|
|
error:
|
|
error instanceof Error ? error.message : "Failed to complete round",
|
|
};
|
|
}
|
|
}
|
|
|
|
return { error: "Invalid action" };
|
|
}
|