feat: Implement bracket expansion plan to support various tournament structures

- 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.
This commit is contained in:
Chris Parsons 2025-11-03 09:36:16 -08:00
parent 60ba3d4a6e
commit a3b8fa6309
22 changed files with 7493 additions and 76 deletions

View file

@ -0,0 +1,166 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Badge } from "~/components/ui/badge";
import { TrendingUp, TrendingDown, Minus, Trophy, Medal, Award } from "lucide-react";
export interface StandingsRow {
teamId: string;
teamName: string;
totalPoints: number;
currentRank: number;
previousRank?: number | null;
firstPlaceCount: number;
secondPlaceCount: number;
thirdPlaceCount: number;
fourthPlaceCount: number;
fifthPlaceCount: number;
sixthPlaceCount: number;
seventhPlaceCount: number;
eighthPlaceCount: number;
participantsRemaining: number;
}
interface StandingsTableProps {
standings: StandingsRow[];
showMovement?: boolean;
showPlacementBreakdown?: boolean;
}
export function StandingsTable({
standings,
showMovement = true,
showPlacementBreakdown = false,
}: StandingsTableProps) {
const getMovementIndicator = (current: number, previous?: number | null) => {
if (!showMovement || !previous) return null;
const change = previous - current; // Positive means moved up
if (change > 0) {
return (
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
<TrendingUp className="h-3 w-3" />
<span className="text-xs">+{change}</span>
</div>
);
} else if (change < 0) {
return (
<div className="flex items-center gap-1 text-red-600 dark:text-red-400">
<TrendingDown className="h-3 w-3" />
<span className="text-xs">{change}</span>
</div>
);
} else {
return (
<div className="flex items-center gap-1 text-muted-foreground">
<Minus className="h-3 w-3" />
<span className="text-xs">-</span>
</div>
);
}
};
const getRankBadge = (rank: number) => {
if (rank === 1) {
return (
<div className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-yellow-500" />
<span className="font-bold text-lg">{rank}</span>
</div>
);
} else if (rank === 2) {
return (
<div className="flex items-center gap-2">
<Medal className="h-5 w-5 text-gray-400" />
<span className="font-semibold">{rank}</span>
</div>
);
} else if (rank === 3) {
return (
<div className="flex items-center gap-2">
<Award className="h-5 w-5 text-amber-700" />
<span className="font-semibold">{rank}</span>
</div>
);
} else {
return <span className="font-medium">{rank}</span>;
}
};
return (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Rank</TableHead>
{showMovement && <TableHead className="w-20">Change</TableHead>}
<TableHead>Team</TableHead>
<TableHead className="text-right">Total Points</TableHead>
{showPlacementBreakdown && (
<>
<TableHead className="text-center w-12" title="1st place finishes">🥇</TableHead>
<TableHead className="text-center w-12" title="2nd place finishes">🥈</TableHead>
<TableHead className="text-center w-12" title="3rd place finishes">🥉</TableHead>
<TableHead className="text-center w-12" title="4th place finishes">4th</TableHead>
<TableHead className="text-center w-12" title="5th place finishes">5th</TableHead>
<TableHead className="text-center w-12" title="6th place finishes">6th</TableHead>
<TableHead className="text-center w-12" title="7th place finishes">7th</TableHead>
<TableHead className="text-center w-12" title="8th place finishes">8th</TableHead>
</>
)}
<TableHead className="text-right">Remaining</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{standings.length === 0 ? (
<TableRow>
<TableCell colSpan={showPlacementBreakdown ? 13 : 5} className="text-center text-muted-foreground">
No standings data available yet
</TableCell>
</TableRow>
) : (
standings.map((row) => (
<TableRow key={row.teamId} className={row.currentRank <= 3 ? "bg-muted/30" : undefined}>
<TableCell>
{getRankBadge(row.currentRank)}
</TableCell>
{showMovement && (
<TableCell>
{getMovementIndicator(row.currentRank, row.previousRank)}
</TableCell>
)}
<TableCell className="font-medium">{row.teamName}</TableCell>
<TableCell className="text-right font-semibold">
{row.totalPoints.toFixed(1)}
</TableCell>
{showPlacementBreakdown && (
<>
<TableCell className="text-center">{row.firstPlaceCount || "-"}</TableCell>
<TableCell className="text-center">{row.secondPlaceCount || "-"}</TableCell>
<TableCell className="text-center">{row.thirdPlaceCount || "-"}</TableCell>
<TableCell className="text-center">{row.fourthPlaceCount || "-"}</TableCell>
<TableCell className="text-center">{row.fifthPlaceCount || "-"}</TableCell>
<TableCell className="text-center">{row.sixthPlaceCount || "-"}</TableCell>
<TableCell className="text-center">{row.seventhPlaceCount || "-"}</TableCell>
<TableCell className="text-center">{row.eighthPlaceCount || "-"}</TableCell>
</>
)}
<TableCell className="text-right">
{row.participantsRemaining > 0 ? (
<Badge variant="secondary">{row.participantsRemaining}</Badge>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
);
}

View file

@ -0,0 +1,207 @@
import { describe, it, expect } from "vitest";
import { calculateFantasyPoints, calculateAveragedPoints, type ScoringRules } from "../scoring-rules";
const DEFAULT_SCORING: ScoringRules = {
pointsFor1st: 100,
pointsFor2nd: 70,
pointsFor3rd: 50,
pointsFor4th: 40,
pointsFor5th: 25,
pointsFor6th: 25,
pointsFor7th: 15,
pointsFor8th: 15,
};
describe("Scoring Calculator", () => {
describe("calculateFantasyPoints", () => {
it("should calculate points for 1st place", () => {
const points = calculateFantasyPoints(1, DEFAULT_SCORING);
expect(points).toBe(100);
});
it("should calculate points for 2nd place", () => {
const points = calculateFantasyPoints(2, DEFAULT_SCORING);
expect(points).toBe(70);
});
it("should calculate points for 8th place", () => {
const points = calculateFantasyPoints(8, DEFAULT_SCORING);
expect(points).toBe(15);
});
it("should return 0 for placements outside 1-8", () => {
const points = calculateFantasyPoints(9, DEFAULT_SCORING);
expect(points).toBe(0);
});
it("should handle custom scoring rules", () => {
const customScoring: ScoringRules = {
pointsFor1st: 200,
pointsFor2nd: 150,
pointsFor3rd: 100,
pointsFor4th: 75,
pointsFor5th: 50,
pointsFor6th: 40,
pointsFor7th: 30,
pointsFor8th: 20,
};
const points = calculateFantasyPoints(1, customScoring);
expect(points).toBe(200);
});
});
describe("calculateAveragedPoints", () => {
it("should average points for two-way tie (SF losers sharing 3rd/4th)", () => {
const points = calculateAveragedPoints([3, 4], DEFAULT_SCORING);
// (50 + 40) / 2 = 45
expect(points).toBe(45);
});
it("should average points for four-way tie (QF losers sharing 5th-8th)", () => {
const points = calculateAveragedPoints([5, 6, 7, 8], DEFAULT_SCORING);
// (25 + 25 + 15 + 15) / 4 = 20
expect(points).toBe(20);
});
it("should average points for three-way tie", () => {
const points = calculateAveragedPoints([1, 2, 3], DEFAULT_SCORING);
// (100 + 70 + 50) / 3 = 73.33...
expect(points).toBeCloseTo(73.33, 2);
});
it("should handle single placement (no tie)", () => {
const points = calculateAveragedPoints([1], DEFAULT_SCORING);
expect(points).toBe(100);
});
it("should return 0 for empty array", () => {
const points = calculateAveragedPoints([], DEFAULT_SCORING);
expect(points).toBe(0);
});
it("should work with custom scoring rules", () => {
const customScoring: ScoringRules = {
pointsFor1st: 80,
pointsFor2nd: 60,
pointsFor3rd: 40,
pointsFor4th: 30,
pointsFor5th: 20,
pointsFor6th: 20,
pointsFor7th: 10,
pointsFor8th: 10,
};
const points = calculateAveragedPoints([5, 6, 7, 8], customScoring);
// (20 + 20 + 10 + 10) / 4 = 15
expect(points).toBe(15);
});
});
describe("Playoff Placement Scenarios", () => {
it("should handle 8-team single elimination bracket", () => {
// Champion: 1st (100 pts)
const champion = calculateFantasyPoints(1, DEFAULT_SCORING);
expect(champion).toBe(100);
// Runner-up: 2nd (70 pts)
const runnerUp = calculateFantasyPoints(2, DEFAULT_SCORING);
expect(runnerUp).toBe(70);
// SF losers: share 3rd/4th (45 pts each)
const sfLosers = calculateAveragedPoints([3, 4], DEFAULT_SCORING);
expect(sfLosers).toBe(45);
// QF losers: share 5th-8th (20 pts each)
const qfLosers = calculateAveragedPoints([5, 6, 7, 8], DEFAULT_SCORING);
expect(qfLosers).toBe(20);
});
it("should handle 4-team bracket (no quarterfinals)", () => {
// Champion: 1st (100 pts)
const champion = calculateFantasyPoints(1, DEFAULT_SCORING);
expect(champion).toBe(100);
// Runner-up: 2nd (70 pts)
const runnerUp = calculateFantasyPoints(2, DEFAULT_SCORING);
expect(runnerUp).toBe(70);
// SF losers: share 3rd/4th (45 pts each)
const sfLosers = calculateAveragedPoints([3, 4], DEFAULT_SCORING);
expect(sfLosers).toBe(45);
});
});
describe("Edge Cases", () => {
it("should handle all participants tying for 1st-8th", () => {
const points = calculateAveragedPoints([1, 2, 3, 4, 5, 6, 7, 8], DEFAULT_SCORING);
// (100 + 70 + 50 + 40 + 25 + 25 + 15 + 15) / 8 = 42.5
expect(points).toBe(42.5);
});
it("should handle placements with same point values", () => {
// With default scoring, 5th and 6th both get 25 points
const points56 = calculateAveragedPoints([5, 6], DEFAULT_SCORING);
expect(points56).toBe(25);
// 7th and 8th both get 15 points
const points78 = calculateAveragedPoints([7, 8], DEFAULT_SCORING);
expect(points78).toBe(15);
});
it("should handle zero points for eliminated participants", () => {
// According to Q20, participants eliminated before Elite Eight get 0 points
const points = calculateFantasyPoints(9, DEFAULT_SCORING);
expect(points).toBe(0);
const points10 = calculateFantasyPoints(16, DEFAULT_SCORING);
expect(points10).toBe(0);
});
});
describe("Real-world Scenarios", () => {
it("should correctly calculate NFL playoff points", () => {
// NFL has 8 teams in playoffs (QF, SF, Finals)
// Super Bowl winner: 100 pts
const winner = calculateFantasyPoints(1, DEFAULT_SCORING);
expect(winner).toBe(100);
// Super Bowl loser: 70 pts
const loser = calculateFantasyPoints(2, DEFAULT_SCORING);
expect(loser).toBe(70);
// Conference championship losers: 45 pts each
const confLosers = calculateAveragedPoints([3, 4], DEFAULT_SCORING);
expect(confLosers).toBe(45);
// Divisional round losers: 20 pts each
const divLosers = calculateAveragedPoints([5, 6, 7, 8], DEFAULT_SCORING);
expect(divLosers).toBe(20);
});
it("should correctly calculate NCAA March Madness Elite Eight points", () => {
// Even though 68 teams start, only Elite Eight (8 teams) get points
// Teams eliminated before Elite Eight get 0 points
// Champion: 100 pts
const champion = calculateFantasyPoints(1, DEFAULT_SCORING);
expect(champion).toBe(100);
// Runner-up: 70 pts
const runnerUp = calculateFantasyPoints(2, DEFAULT_SCORING);
expect(runnerUp).toBe(70);
// Final Four losers: 45 pts each
const ffLosers = calculateAveragedPoints([3, 4], DEFAULT_SCORING);
expect(ffLosers).toBe(45);
// Elite Eight losers: 20 pts each
const e8Losers = calculateAveragedPoints([5, 6, 7, 8], DEFAULT_SCORING);
expect(e8Losers).toBe(20);
// Sweet Sixteen loser (example): 0 pts
const sweetSixteenLoser = calculateFantasyPoints(9, DEFAULT_SCORING);
expect(sweetSixteenLoser).toBe(0);
});
});
});

View file

@ -101,20 +101,8 @@ export async function deleteParticipantResultsBySportsSeasonId(
} }
/** /**
* Calculate points awarded based on final position * Set result for a participant in a sports season
* 1st: 80, 2nd: 50, 3rd-4th: 30, 5th-8th: 20, 9th+: 0 * Points are calculated on-demand based on each fantasy league's scoring rules
*/
export function calculatePointsFromPosition(position: number | null): number {
if (position === null || position < 1) return 0;
if (position === 1) return 80;
if (position === 2) return 50;
if (position >= 3 && position <= 4) return 30;
if (position >= 5 && position <= 8) return 20;
return 0;
}
/**
* Set result with automatic point calculation
*/ */
export async function setParticipantResult( export async function setParticipantResult(
participantId: string, participantId: string,
@ -124,7 +112,6 @@ export async function setParticipantResult(
notes?: string notes?: string
): Promise<ParticipantResult> { ): Promise<ParticipantResult> {
const db = database(); const db = database();
const pointsAwarded = calculatePointsFromPosition(finalPosition);
// Check if result already exists // Check if result already exists
const existing = await db.query.participantResults.findFirst({ const existing = await db.query.participantResults.findFirst({
@ -138,7 +125,6 @@ export async function setParticipantResult(
// Update existing result // Update existing result
return await updateParticipantResult(existing.id, { return await updateParticipantResult(existing.id, {
finalPosition, finalPosition,
pointsAwarded,
qualifyingPoints: qualifyingPoints?.toString(), qualifyingPoints: qualifyingPoints?.toString(),
notes, notes,
}); });
@ -148,7 +134,6 @@ export async function setParticipantResult(
participantId, participantId,
sportsSeasonId, sportsSeasonId,
finalPosition, finalPosition,
pointsAwarded,
qualifyingPoints: qualifyingPoints?.toString(), qualifyingPoints: qualifyingPoints?.toString(),
notes, notes,
}); });

251
app/models/playoff-match.ts Normal file
View file

@ -0,0 +1,251 @@
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 });
}

View file

@ -16,7 +16,11 @@ export type ScoringPattern =
/** /**
* Process a playoff event completion and assign final placements * Process a playoff event completion and assign final placements
* Implementation will be added in Phase 2 *
* Q19: Admin enters the same placement for all tied participants,
* system automatically shares positions
*
* Q20: Participants eliminated before Elite Eight get 0 points
*/ */
export async function processPlayoffEvent( export async function processPlayoffEvent(
eventId: string, eventId: string,
@ -24,14 +28,134 @@ export async function processPlayoffEvent(
): Promise<void> { ): Promise<void> {
const db = providedDb || database(); const db = providedDb || database();
// TODO: Implement in Phase 2 // Get the event
// 1. Get event and determine round (QF, SF, Finals) const event = await db.query.scoringEvents.findFirst({
// 2. Get matches and determine winners/losers where: eq(schema.scoringEvents.id, eventId),
// 3. Assign shared placements based on elimination round with: {
// 4. Update participantResults with final placements sportsSeason: {
// 5. Trigger recalculation for affected leagues with: {
seasonSports: {
with: {
season: true,
},
},
},
},
},
});
console.log(`[ScoringCalculator] processPlayoffEvent not yet implemented: ${eventId}`); if (!event) {
throw new Error(`Event ${eventId} not found`);
}
if (!event.playoffRound) {
throw new Error(`Event ${eventId} is not a playoff event`);
}
// Get all matches for this event
const matches = await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, eventId),
orderBy: (matches, { asc }) => [asc(matches.matchNumber)],
});
// Process based on round
const round = event.playoffRound;
// Get season scoring rules for the first affected season
// (all seasons should have their own rules, but we use the event's sports season to find one)
const seasonSport = event.sportsSeason.seasonSports[0];
if (!seasonSport) {
throw new Error(`No fantasy seasons found for sports season ${event.sportsSeasonId}`);
}
const scoringRules = await getScoringRules(seasonSport.seasonId, db);
if (!scoringRules) {
throw new Error(`Scoring rules not found for season ${seasonSport.seasonId}`);
}
if (round === "Finals") {
// Winner gets 1st, loser gets 2nd
const finalMatch = matches[0];
if (!finalMatch || !finalMatch.winnerId || !finalMatch.loserId) {
throw new Error("Finals match is not complete");
}
// Update or create participant results for winner
await upsertParticipantResult(
finalMatch.winnerId,
event.sportsSeasonId,
1,
db
);
// Update or create participant results for loser
await upsertParticipantResult(
finalMatch.loserId,
event.sportsSeasonId,
2,
db
);
} else if (round === "Semifinals") {
// Losers share 3rd/4th
for (const match of matches) {
if (match.loserId) {
await upsertParticipantResult(
match.loserId,
event.sportsSeasonId,
3, // Store as placement 3 (the first shared placement)
db
);
}
}
} else if (round === "Quarterfinals") {
// Losers share 5th-8th
for (const match of matches) {
if (match.loserId) {
await upsertParticipantResult(
match.loserId,
event.sportsSeasonId,
5, // Store as placement 5 (the first shared placement)
db
);
}
}
}
// Recalculate standings for all affected leagues
await recalculateAffectedLeagues(event.sportsSeasonId, db);
}
/**
* Helper to upsert participant result
*/
async function upsertParticipantResult(
participantId: string,
sportsSeasonId: string,
finalPosition: number,
db: ReturnType<typeof database>
): Promise<void> {
const existing = await db.query.participantResults.findFirst({
where: and(
eq(schema.participantResults.participantId, participantId),
eq(schema.participantResults.sportsSeasonId, sportsSeasonId)
),
});
if (existing) {
await db
.update(schema.participantResults)
.set({
finalPosition,
updatedAt: new Date(),
})
.where(eq(schema.participantResults.id, existing.id));
} else {
await db.insert(schema.participantResults).values({
participantId,
sportsSeasonId,
finalPosition,
});
}
} }
/** /**

View file

@ -2,7 +2,7 @@ import { database } from "~/database/context";
import * as schema from "~/database/schema"; import * as schema from "~/database/schema";
import { eq, and, desc } from "drizzle-orm"; import { eq, and, desc } from "drizzle-orm";
export type EventType = "playoff_game" | "major_tournament" | "race" | "final_standings"; export type EventType = "playoff_game" | "major_tournament" | "final_standings";
export interface CreateScoringEventData { export interface CreateScoringEventData {
sportsSeasonId: string; sportsSeasonId: string;
@ -16,6 +16,7 @@ export interface CreateScoringEventData {
export interface UpdateScoringEventData { export interface UpdateScoringEventData {
name?: string; name?: string;
eventDate?: Date; eventDate?: Date;
playoffRound?: string;
isComplete?: boolean; isComplete?: boolean;
completedAt?: Date; completedAt?: Date;
} }

View file

@ -52,6 +52,10 @@ export default [
"sports-seasons/:id/events/:eventId", "sports-seasons/:id/events/:eventId",
"routes/admin.sports-seasons.$id.events.$eventId.tsx" "routes/admin.sports-seasons.$id.events.$eventId.tsx"
), ),
route(
"sports-seasons/:id/events/:eventId/bracket",
"routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx"
),
route("participants", "routes/admin.participants.tsx"), route("participants", "routes/admin.participants.tsx"),
route("templates", "routes/admin.templates.tsx"), route("templates", "routes/admin.templates.tsx"),
route("templates/new", "routes/admin.templates.new.tsx"), route("templates/new", "routes/admin.templates.new.tsx"),

View file

@ -0,0 +1,255 @@
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" };
}

View file

@ -0,0 +1,278 @@
import { Form, Link } from "react-router";
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
import { loader, action } from "./admin.sports-seasons.$id.events.$eventId.bracket.server";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { ArrowLeft, Plus, Trophy } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
export { loader, action };
export default function EventBracket({
loaderData,
actionData,
}: Route.ComponentProps) {
const { sportsSeason, event, participants, matches } = loaderData;
// Group matches by round
const matchesByRound: Record<string, typeof matches> = {};
for (const match of matches) {
if (!matchesByRound[match.round]) {
matchesByRound[match.round] = [];
}
matchesByRound[match.round].push(match);
}
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
const availableRounds = rounds.filter(r => matchesByRound[r]);
// Get participants not yet in any match
const participantsInMatches = new Set(
matches.flatMap(m => [m.participant1Id, m.participant2Id].filter(Boolean))
);
const availableParticipants = participants.filter(
(p: { id: string }) => !participantsInMatches.has(p.id)
);
return (
<div className="p-8">
<div className="max-w-6xl mx-auto">
<div className="mb-6">
<Button variant="ghost" size="sm" asChild className="mb-2">
<Link to={`/admin/sports-seasons/${sportsSeason.id}/events/${event.id}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Event
</Link>
</Button>
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Playoff Bracket</h1>
<p className="text-muted-foreground mt-1">
{event.name} - {sportsSeason.sport.name} {sportsSeason.name}
</p>
</div>
</div>
</div>
<div className="space-y-6">
{actionData?.error && (
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.error}
</div>
)}
{actionData?.success && (
<div className="bg-green-500/15 text-green-700 dark:text-green-400 px-4 py-3 rounded-md text-sm">
{actionData.success}
</div>
)}
{/* Generate Bracket Form */}
{matches.length === 0 && (
<Card>
<CardHeader>
<CardTitle>Generate Bracket</CardTitle>
<CardDescription>
Create the bracket structure for this playoff event
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<input type="hidden" name="intent" value="generate-bracket" />
<div className="space-y-2">
<Label htmlFor="bracketSize">Bracket Size</Label>
<Select name="bracketSize" defaultValue="8" required>
<SelectTrigger id="bracketSize">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="4">4 teams (Semifinals + Finals)</SelectItem>
<SelectItem value="8">8 teams (Quarterfinals + Semifinals + Finals)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Select Participants (in order)</Label>
<p className="text-sm text-muted-foreground">
Select {8} participants for the bracket
</p>
{[...Array(8)].map((_, i) => (
<Select key={i} name={`participant${i}`} required>
<SelectTrigger>
<SelectValue placeholder={`Seed ${i + 1}`} />
</SelectTrigger>
<SelectContent>
{participants.map((participant: { id: string; name: string }) => (
<SelectItem key={participant.id} value={participant.id}>
{participant.name}
</SelectItem>
))}
</SelectContent>
</Select>
))}
</div>
<Button type="submit" className="w-full">
<Plus className="mr-2 h-4 w-4" />
Generate Bracket
</Button>
</Form>
</CardContent>
</Card>
)}
{/* Bracket Display */}
{availableRounds.map(round => (
<Card key={round}>
<CardHeader>
<CardTitle>{round}</CardTitle>
<CardDescription>
{matchesByRound[round].length} {matchesByRound[round].length === 1 ? "match" : "matches"}
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Match</TableHead>
<TableHead>Participant 1</TableHead>
<TableHead>Score</TableHead>
<TableHead className="w-24 text-center">vs</TableHead>
<TableHead>Score</TableHead>
<TableHead>Participant 2</TableHead>
<TableHead>Winner</TableHead>
<TableHead className="w-32">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{matchesByRound[round].map((match) => (
<TableRow key={match.id}>
<TableCell className="font-semibold">
{match.matchNumber}
</TableCell>
<TableCell>
{match.participant1?.name || "TBD"}
</TableCell>
<TableCell>
{match.participant1Score ? parseFloat(match.participant1Score) : "-"}
</TableCell>
<TableCell className="text-center text-muted-foreground">
vs
</TableCell>
<TableCell>
{match.participant2Score ? parseFloat(match.participant2Score) : "-"}
</TableCell>
<TableCell>
{match.participant2?.name || "TBD"}
</TableCell>
<TableCell>
{match.winner?.name ? (
<div className="flex items-center gap-1">
<Trophy className="h-4 w-4 text-yellow-500" />
{match.winner.name}
</div>
) : (
"-"
)}
</TableCell>
<TableCell>
{!match.isComplete && match.participant1Id && match.participant2Id ? (
<Form method="post" className="inline">
<input type="hidden" name="intent" value="set-winner" />
<input type="hidden" name="matchId" value={match.id} />
<Select name="winnerId" required>
<SelectTrigger className="h-8 w-full">
<SelectValue placeholder="Set winner" />
</SelectTrigger>
<SelectContent>
<SelectItem value={match.participant1Id}>
{match.participant1?.name}
</SelectItem>
<SelectItem value={match.participant2Id}>
{match.participant2?.name}
</SelectItem>
</SelectContent>
</Select>
<Button type="submit" size="sm" className="ml-2 h-8">
Set
</Button>
</Form>
) : match.isComplete ? (
<span className="text-sm text-muted-foreground">Complete</span>
) : (
<span className="text-sm text-muted-foreground">Waiting</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
))}
{/* Complete Round Button */}
{availableRounds.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Complete Round</CardTitle>
<CardDescription>
Finalize the current round and calculate placements
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post">
<input type="hidden" name="intent" value="complete-round" />
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="round">Round to Complete</Label>
<Select name="round" required>
<SelectTrigger id="round">
<SelectValue placeholder="Select round" />
</SelectTrigger>
<SelectContent>
{availableRounds.map(round => (
<SelectItem key={round} value={round}>
{round}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="submit">
Complete Round & Calculate Placements
</Button>
</div>
</Form>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}

View file

@ -19,7 +19,7 @@ import {
SelectValue, SelectValue,
} from "~/components/ui/select"; } from "~/components/ui/select";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { ArrowLeft, Trophy, CheckCircle2, Pencil, Trash2 } from "lucide-react"; import { ArrowLeft, Trophy, CheckCircle2, Pencil, Trash2, Brackets } from "lucide-react";
import { import {
Table, Table,
TableBody, TableBody,
@ -53,11 +53,9 @@ export default function EventResults({
const getEventTypeLabel = (type: string) => { const getEventTypeLabel = (type: string) => {
switch (type) { switch (type) {
case "playoff_game": case "playoff_game":
return "Playoff Game"; return "Bracket";
case "major_tournament": case "major_tournament":
return "Major Tournament"; return "Major Tournament";
case "race":
return "Race";
case "final_standings": case "final_standings":
return "Final Standings"; return "Final Standings";
default: default:
@ -84,14 +82,24 @@ export default function EventResults({
{event.playoffRound && `${event.playoffRound}`} {event.playoffRound && `${event.playoffRound}`}
</p> </p>
</div> </div>
{event.isComplete ? ( <div className="flex items-center gap-2">
<Badge variant="default" className="bg-green-500"> {event.eventType === "playoff_game" && (
<CheckCircle2 className="mr-1 h-3 w-3" /> <Button variant="outline" asChild>
Completed <Link to={`/admin/sports-seasons/${sportsSeason.id}/events/${event.id}/bracket`}>
</Badge> <Brackets className="mr-2 h-4 w-4" />
) : ( Manage Bracket
<Badge variant="secondary">In Progress</Badge> </Link>
)} </Button>
)}
{event.isComplete ? (
<Badge variant="default" className="bg-green-500">
<CheckCircle2 className="mr-1 h-3 w-3" />
Completed
</Badge>
) : (
<Badge variant="secondary">In Progress</Badge>
)}
</div>
</div> </div>
</div> </div>

View file

@ -58,7 +58,6 @@ export async function action({ request, params }: Route.ActionArgs) {
if ( if (
eventType !== "playoff_game" && eventType !== "playoff_game" &&
eventType !== "major_tournament" && eventType !== "major_tournament" &&
eventType !== "race" &&
eventType !== "final_standings" eventType !== "final_standings"
) { ) {
return { error: "Invalid event type" }; return { error: "Invalid event type" };
@ -67,7 +66,7 @@ export async function action({ request, params }: Route.ActionArgs) {
const eventData: CreateScoringEventData = { const eventData: CreateScoringEventData = {
sportsSeasonId: params.id, sportsSeasonId: params.id,
name: name.trim(), name: name.trim(),
eventType: eventType as "playoff_game" | "major_tournament" | "race" | "final_standings", eventType: eventType as "playoff_game" | "major_tournament" | "final_standings",
eventDate: typeof eventDate === "string" && eventDate ? new Date(eventDate) : undefined, eventDate: typeof eventDate === "string" && eventDate ? new Date(eventDate) : undefined,
playoffRound: typeof playoffRound === "string" && playoffRound ? playoffRound : undefined, playoffRound: typeof playoffRound === "string" && playoffRound ? playoffRound : undefined,
}; };

View file

@ -33,11 +33,9 @@ export default function SportsSeasonEvents({
const getEventTypeLabel = (type: string) => { const getEventTypeLabel = (type: string) => {
switch (type) { switch (type) {
case "playoff_game": case "playoff_game":
return "Playoff Game"; return "Bracket";
case "major_tournament": case "major_tournament":
return "Major Tournament"; return "Major Tournament";
case "race":
return "Race";
case "final_standings": case "final_standings":
return "Final Standings"; return "Final Standings";
default: default:
@ -77,7 +75,7 @@ export default function SportsSeasonEvents({
<CardHeader> <CardHeader>
<CardTitle>Create New Event</CardTitle> <CardTitle>Create New Event</CardTitle>
<CardDescription> <CardDescription>
Add a new scoring event (game, tournament, race, etc.) Add a new scoring event (bracket, tournament, standings, etc.)
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -101,9 +99,8 @@ export default function SportsSeasonEvents({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="playoff_game">Playoff Game</SelectItem> <SelectItem value="playoff_game">Bracket</SelectItem>
<SelectItem value="major_tournament">Major Tournament</SelectItem> <SelectItem value="major_tournament">Major Tournament</SelectItem>
<SelectItem value="race">Race</SelectItem>
<SelectItem value="final_standings">Final Standings</SelectItem> <SelectItem value="final_standings">Final Standings</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>

View file

@ -30,7 +30,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from "~/components/ui/alert-dialog"; } from "~/components/ui/alert-dialog";
import { Trash2, Users } from "lucide-react"; import { Trash2, Users, Trophy } from "lucide-react";
export async function loader({ params }: Route.LoaderArgs) { export async function loader({ params }: Route.LoaderArgs) {
const sportsSeason = await findSportsSeasonById(params.id); const sportsSeason = await findSportsSeasonById(params.id);
@ -266,6 +266,31 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Scoring Events</CardTitle>
<CardDescription>
Manage games, tournaments, and results
</CardDescription>
</div>
<Button
size="sm"
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/events`)}
>
<Trophy className="mr-2 h-4 w-4" />
Manage Events
</Button>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Create playoff games, tournaments, races, or standings events to track participant results and calculate fantasy points.
</p>
</CardContent>
</Card>
<Card className="border-destructive"> <Card className="border-destructive">
<CardHeader> <CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle> <CardTitle className="text-destructive">Danger Zone</CardTitle>

View file

@ -49,7 +49,6 @@ export const scoringPatternEnum = pgEnum("scoring_pattern", [
export const eventTypeEnum = pgEnum("event_type", [ export const eventTypeEnum = pgEnum("event_type", [
"playoff_game", "playoff_game",
"major_tournament", "major_tournament",
"race",
"final_standings", "final_standings",
]); ]);
@ -288,7 +287,6 @@ export const participantResults = pgTable("participant_results", {
.notNull() .notNull()
.references(() => sportsSeasons.id, { onDelete: "cascade" }), .references(() => sportsSeasons.id, { onDelete: "cascade" }),
finalPosition: integer("final_position"), finalPosition: integer("final_position"),
pointsAwarded: integer("points_awarded").notNull().default(0),
qualifyingPoints: decimal("qualifying_points", { precision: 10, scale: 2 }), qualifyingPoints: decimal("qualifying_points", { precision: 10, scale: 2 }),
notes: text("notes"), notes: text("notes"),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),

View file

@ -0,0 +1,4 @@
ALTER TABLE "public"."scoring_events" ALTER COLUMN "event_type" SET DATA TYPE text;--> statement-breakpoint
DROP TYPE "public"."event_type";--> statement-breakpoint
CREATE TYPE "public"."event_type" AS ENUM('playoff_game', 'major_tournament', 'final_standings');--> statement-breakpoint
ALTER TABLE "public"."scoring_events" ALTER COLUMN "event_type" SET DATA TYPE "public"."event_type" USING "event_type"::"public"."event_type";

View file

@ -0,0 +1 @@
ALTER TABLE "participant_results" DROP COLUMN IF EXISTS "points_awarded";

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -148,6 +148,20 @@
"when": 1761719248881, "when": 1761719248881,
"tag": "0020_slim_spacker_dave", "tag": "0020_slim_spacker_dave",
"breakpoints": true "breakpoints": true
},
{
"idx": 21,
"version": "7",
"when": 1762190473418,
"tag": "0021_fair_ser_duncan",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1762191156317,
"tag": "0022_shiny_mother_askani",
"breakpoints": true
} }
] ]
} }

345
plans/bracket-expansion.md Normal file
View file

@ -0,0 +1,345 @@
# Bracket Expansion Plan
## Problem Statement
Current bracket system only supports 4 and 8 team single-elimination brackets. We need to support:
- **Standard brackets**: 4, 8, 16, 32 teams
- **March Madness**: 68 teams with unique structure
- First Four: 4 play-in games (8 teams)
- Round of 64: 32 games (64 teams including First Four winners)
- Round of 32, Sweet Sixteen, Elite Eight, Final Four, Championship
- **Only Elite Eight and beyond score fantasy points** (per Q18)
- Play-in teams can be any seeds (e.g., 11a vs 11b, 16a vs 16b), not just bottom seeds
## Current Implementation
**What we have:**
- `generateSingleEliminationBracket(eventId, participantIds[])`
- Supports 4 or 8 teams only
- Creates matches for all rounds automatically
- Simple sequential seeding (1 vs 2, 3 vs 4, etc.)
**Limitations:**
- Fixed bracket sizes (4, 8)
- No play-in game support
- No concept of "scoring rounds" vs "non-scoring rounds"
- No flexible seeding/matchup configuration
## Proposed Solution: Hybrid Approach
### Option 1: Template-Based Bracket System ✅ **RECOMMENDED**
Create pre-defined bracket templates for common structures, with manual override capability.
#### Bracket Templates
```typescript
interface BracketTemplate {
id: string;
name: string;
totalTeams: number;
rounds: BracketRound[];
scoringStartsAtRound?: string; // e.g., "Elite Eight" for March Madness
}
interface BracketRound {
name: string; // "First Four", "Round of 64", "Elite Eight", etc.
matchCount: number;
feedsInto?: string; // Which round winners advance to
isScoring: boolean; // Does this round affect fantasy points?
}
// Example templates:
const BRACKET_TEMPLATES = {
NCAA_68: {
id: 'ncaa_68',
name: 'NCAA March Madness (68 teams)',
totalTeams: 68,
scoringStartsAtRound: 'Elite Eight',
rounds: [
{ name: 'First Four', matchCount: 4, feedsInto: 'Round of 64', isScoring: false },
{ name: 'Round of 64', matchCount: 32, feedsInto: 'Round of 32', isScoring: false },
{ name: 'Round of 32', matchCount: 16, feedsInto: 'Sweet Sixteen', isScoring: false },
{ name: 'Sweet Sixteen', matchCount: 8, feedsInto: 'Elite Eight', isScoring: false },
{ name: 'Elite Eight', matchCount: 4, feedsInto: 'Final Four', isScoring: true }, // 8 teams, share 5-8th
{ name: 'Final Four', matchCount: 2, feedsInto: 'Championship', isScoring: true }, // Losers share 3-4th
{ name: 'Championship', matchCount: 1, feedsInto: null, isScoring: true }, // Winner 1st, Loser 2nd
],
},
NFL_14: {
id: 'nfl_14',
name: 'NFL Playoffs (14 teams)',
totalTeams: 14,
scoringStartsAtRound: 'Wild Card',
rounds: [
{ name: 'Wild Card', matchCount: 6, feedsInto: 'Divisional', isScoring: false }, // 2 teams get bye
{ name: 'Divisional', matchCount: 4, feedsInto: 'Conference Championship', isScoring: true }, // QF, share 5-8th
{ name: 'Conference Championship', matchCount: 2, feedsInto: 'Super Bowl', isScoring: true }, // SF, share 3-4th
{ name: 'Super Bowl', matchCount: 1, feedsInto: null, isScoring: true }, // Finals, 1st/2nd
],
},
NBA_16: {
id: 'nba_16',
name: 'NBA Playoffs (16 teams)',
totalTeams: 16,
scoringStartsAtRound: 'Conference Quarterfinals',
rounds: [
{ name: 'First Round', matchCount: 8, feedsInto: 'Conference Semifinals', isScoring: false },
{ name: 'Conference Semifinals', matchCount: 4, feedsInto: 'Conference Finals', isScoring: true }, // QF, share 5-8th
{ name: 'Conference Finals', matchCount: 2, feedsInto: 'NBA Finals', isScoring: true }, // SF, share 3-4th
{ name: 'NBA Finals', matchCount: 1, feedsInto: null, isScoring: true }, // Finals, 1st/2nd
],
},
SIMPLE_16: {
id: 'simple_16',
name: 'Simple 16-Team Bracket',
totalTeams: 16,
scoringStartsAtRound: 'Quarterfinals',
rounds: [
{ name: 'Round of 16', matchCount: 8, feedsInto: 'Quarterfinals', isScoring: false },
{ name: 'Quarterfinals', matchCount: 4, feedsInto: 'Semifinals', isScoring: true }, // Share 5-8th
{ name: 'Semifinals', matchCount: 2, feedsInto: 'Finals', isScoring: true }, // Share 3-4th
{ name: 'Finals', matchCount: 1, feedsInto: null, isScoring: true }, // 1st/2nd
],
},
SIMPLE_32: {
id: 'simple_32',
name: 'Simple 32-Team Bracket',
totalTeams: 32,
scoringStartsAtRound: 'Quarterfinals',
rounds: [
{ name: 'Round of 32', matchCount: 16, feedsInto: 'Round of 16', isScoring: false },
{ name: 'Round of 16', matchCount: 8, feedsInto: 'Quarterfinals', isScoring: false },
{ name: 'Quarterfinals', matchCount: 4, feedsInto: 'Semifinals', isScoring: true },
{ name: 'Semifinals', matchCount: 2, feedsInto: 'Finals', isScoring: true },
{ name: 'Finals', matchCount: 1, feedsInto: null, isScoring: true },
],
},
};
```
#### UI Flow
**Step 1: Select Template**
```
When creating a bracket event:
1. Admin selects bracket template from dropdown
2. Shows preview of rounds and scoring structure
3. Option to customize (add/remove rounds, change scoring start point)
```
**Step 2: Assign Participants**
```
For March Madness (68 teams):
- Show all 68 slots grouped by round
- First Four: 8 slots (4 matchups) - manual seeding (e.g., "11a vs 11b", "16a vs 16b")
- Round of 64: 60 slots (remaining teams) + 4 TBD (from First Four winners)
- Admin can assign any participant to any slot
For simpler brackets (4, 8, 16):
- Linear list of slots
- Drag-and-drop or dropdown selection
- Traditional seeding (1 vs 16, 2 vs 15, etc.)
```
**Step 3: Generate Bracket**
```
System creates:
- All playoff_matches records for all rounds
- Properly linked feedsInto relationships
- Marks which rounds contribute to scoring
- Sets initial participant assignments
```
#### Database Schema Additions
```typescript
// Add to playoff_matches table
playoff_matches {
// ... existing fields
// NEW FIELDS:
isScoring: boolean (default: true) // Does this match affect fantasy points?
templateRound: varchar(50) // "First Four", "Round of 64", etc.
seedInfo: varchar(50) // "1 vs 16", "11a vs 11b", etc. (for display)
}
// Add to scoring_events table
scoring_events {
// ... existing fields
// NEW FIELDS:
bracketTemplateId: varchar(50) // "ncaa_68", "nfl_14", etc.
scoringStartsAtRound: varchar(50) // Which round starts fantasy scoring
}
```
#### Scoring Logic Updates
```typescript
/**
* Process playoff event completion - updated for template system
*/
async function processPlayoffEvent(eventId: string) {
const event = await getScoringEventById(eventId);
const matches = await findPlayoffMatchesByEventId(eventId);
// Filter to only scoring matches
const scoringMatches = matches.filter(m => m.isScoring);
// Determine which placements to assign based on templateRound
// Elite Eight losers (4 teams) -> share 5-8th
// Final Four losers (2 teams) -> share 3-4th
// Championship loser (1 team) -> 2nd
// Championship winner (1 team) -> 1st
// For non-scoring rounds: assign 0 points (per Q20)
const nonScoringLosers = matches
.filter(m => !m.isScoring && m.loserId)
.map(m => m.loserId);
for (const loserId of nonScoringLosers) {
await setParticipantResult(loserId, event.sportsSeasonId, null, 0);
}
}
```
#### Admin UI Changes
**Bracket Generation Page:**
```tsx
<Card>
<CardHeader>
<CardTitle>Select Bracket Template</CardTitle>
</CardHeader>
<CardContent>
<Select name="templateId">
<SelectItem value="simple_4">Simple 4-Team</SelectItem>
<SelectItem value="simple_8">Simple 8-Team</SelectItem>
<SelectItem value="simple_16">Simple 16-Team</SelectItem>
<SelectItem value="nba_16">NBA Playoffs (16 teams)</SelectItem>
<SelectItem value="nfl_14">NFL Playoffs (14 teams)</SelectItem>
<SelectItem value="simple_32">Simple 32-Team</SelectItem>
<SelectItem value="ncaa_68">March Madness (68 teams)</SelectItem>
</Select>
{/* Show template details when selected */}
<div className="mt-4">
<h4>Rounds:</h4>
{selectedTemplate.rounds.map(round => (
<div key={round.name}>
{round.name} - {round.matchCount} matches
{round.isScoring && <Badge>Scoring Round</Badge>}
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Assign Participants ({templateTeamCount} slots)</CardTitle>
</CardHeader>
<CardContent>
{/* For March Madness: group by round */}
{template.id === 'ncaa_68' ? (
<>
<h4>First Four (Play-in Games)</h4>
{/* 4 matchups, 8 slots with seed labels */}
<MatchupSelector
matchup={1}
label1="11a vs"
label2="11b"
participants={participants}
/>
<h4>Round of 64</h4>
{/* 28 direct assignments + 4 TBD from First Four */}
<SlotSelector
slots={60}
tbdSlots={4}
participants={participants}
/>
</>
) : (
/* Simple linear assignment for other brackets */
<LinearSlotSelector
slots={templateTeamCount}
participants={participants}
/>
)}
</CardContent>
</Card>
```
### Implementation Phases
**Phase 2.6: Template System Foundation**
- [ ] Define bracket template type and constants
- [ ] Add `isScoring` and `templateRound` to playoff_matches schema
- [ ] Add `bracketTemplateId` and `scoringStartsAtRound` to scoring_events
- [ ] Create database migration
- [ ] Update playoff-match model to support templates
**Phase 2.7: Simple Template Expansion (4, 8, 16, 32)**
- [ ] Create template definitions for 4, 8, 16, 32 team brackets
- [ ] Update `generateBracketFromTemplate()` function
- [ ] Update bracket generation UI to use template selector
- [ ] Test with 16-team and 32-team brackets
- [ ] Update scoring calculator for non-scoring rounds (0 points)
**Phase 2.8: March Madness Template (68 teams)**
- [ ] Create NCAA_68 template definition
- [ ] Build First Four matchup assignment UI
- [ ] Build Round of 64 with TBD slot handling
- [ ] Implement winner advancement from First Four to Round of 64
- [ ] Test complete 68-team bracket flow
- [ ] Verify Elite Eight scoring starts correctly
**Phase 2.9: NFL/NBA Templates**
- [ ] Create NFL_14 template (with bye weeks)
- [ ] Create NBA_16 template
- [ ] Handle bye week logic (teams skip first round)
- [ ] Test both templates
## Alternative Considered: Manual Match Creation
**Pros:**
- Maximum flexibility
- Works for any structure
**Cons:**
- Too complex for admins
- Error-prone (easy to miss connections)
- No validation of bracket structure
- Time-consuming for large brackets
**Verdict:** Not recommended. Templates provide better UX with flexibility where needed.
## Open Questions
1. **Custom Templates**: Should admins be able to save custom bracket templates for reuse?
- **Recommendation**: Not in initial implementation. Add if requested.
2. **Seed Validation**: Should system validate proper bracket structure (e.g., 1 seed plays 16 seed)?
- **Recommendation**: No. Allow flexible seeding for play-ins and special cases.
3. **Series Support**: Should we support best-of-7 series (NBA/NHL)?
- **Recommendation**: Not initially. Just track series winner for now.
4. **Bracket Visualization**: Show traditional bracket tree diagram?
- **Recommendation**: Phase 3 enhancement. Current table view sufficient for Phase 2.
## Success Criteria
✅ Admin can create 4, 8, 16, 32 team brackets
✅ Admin can create March Madness 68-team bracket with First Four
✅ Admin can assign any participant to any slot (flexible seeding)
✅ System correctly identifies which rounds score fantasy points
✅ Participants eliminated in non-scoring rounds get 0 points
✅ Elite Eight and beyond calculate placements correctly
✅ Winner advancement works across all bracket sizes

337
plans/phase2-bugs-review.md Normal file
View file

@ -0,0 +1,337 @@
# Phase 2 Code Review - Bug Report
## 🔴 CRITICAL BUGS
### Bug #1: Winner Advancement Logic Is Incorrect
**File:** `app/models/playoff-match.ts:205-247`
**Issue:** The `advanceWinner()` function uses "fill participant1 first, then participant2" logic, which doesn't guarantee proper bracket structure.
**Current Code:**
```typescript
// Determine which participant slot to fill
// For simplicity, fill participant1 first, then participant2
if (!nextMatch.participant1Id) {
await updatePlayoffMatch(nextMatch.id, { participant1Id: winnerId });
} else if (!nextMatch.participant2Id) {
await updatePlayoffMatch(nextMatch.id, { participant2Id: winnerId });
}
```
**Problem:**
For Quarterfinals → Semifinals:
- QF Match 1 winner should → SF Match 1, participant1
- QF Match 2 winner should → SF Match 1, participant2
- QF Match 3 winner should → SF Match 2, participant1
- QF Match 4 winner should → SF Match 2, participant2
But with current logic:
- QF Match 1 winner → SF Match 1, participant1 ✅
- QF Match 2 winner → SF Match 1, participant2 ✅
- QF Match 3 winner → SF Match 2, participant1 ✅
- QF Match 4 winner → SF Match 2, participant2 ✅
Actually, this WORKS for simple cases, but breaks if matches complete out of order:
- If QF Match 2 completes before Match 1, winner goes to SF1 participant1
- Then QF Match 1 winner goes to SF1 participant2
- **This reverses the bracket structure!**
**Fix:**
```typescript
// Determine which participant slot to fill based on match number
if (match.round === "Quarterfinals") {
nextRound = "Semifinals";
nextMatchNumber = match.matchNumber <= 2 ? 1 : 2;
// Odd matches (1, 3) → participant1, Even matches (2, 4) → participant2
const participantSlot = match.matchNumber % 2 === 1 ? 'participant1Id' : 'participant2Id';
await updatePlayoffMatch(nextMatch.id, { [participantSlot]: winnerId });
} else if (match.round === "Semifinals") {
nextRound = "Finals";
nextMatchNumber = 1;
// SF Match 1 → Finals participant1, SF Match 2 → Finals participant2
const participantSlot = match.matchNumber === 1 ? 'participant1Id' : 'participant2Id';
await updatePlayoffMatch(nextMatch.id, { [participantSlot]: winnerId });
}
```
**Impact:** Bracket structure breaks if matches complete out of order. Semifinals and Finals may have wrong matchups.
**Covered in Future Plans?** Yes, bracket-expansion.md will rewrite this entirely with template system.
---
### Bug #2: Multiple Fantasy Leagues with Different Scoring Rules
**File:** `app/models/scoring-calculator.ts:66-74`
**Issue:** When processing playoff events, we only use the FIRST fantasy season's scoring rules, even if multiple fantasy leagues with different rules use the same sports season.
**Current Code:**
```typescript
const seasonSport = event.sportsSeason.seasonSports[0];
if (!seasonSport) {
throw new Error(`No fantasy seasons found for sports season ${event.sportsSeasonId}`);
}
const scoringRules = await getScoringRules(seasonSport.seasonId, db);
```
**Problem:**
Scenario:
- League A uses "2024 NFL Playoffs" with scoring: 1st=100, 2nd=70
- League B uses "2024 NFL Playoffs" with scoring: 1st=200, 2nd=150
When Super Bowl completes:
- We calculate points using League A's rules (100 pts)
- Store `pointsAwarded: 100` in `participantResults`
- League B also sees 100 pts instead of 200 pts
**Actually Not a Bug (Misleading Design):**
Upon further review, `pointsAwarded` in `participantResults` is stored but **never used**!
In `calculateTeamScore()` (line 294):
```typescript
const points = calculateFantasyPoints(result.finalPosition, scoringRules);
```
It recalculates points from `finalPosition` using each fantasy season's own scoring rules.
**The Real Issues:**
1. **Confusing Design**: We store `pointsAwarded` but never use it
2. **Wasted Storage**: Storing incorrect values that are never read
3. **Potential Future Bug**: Someone might assume `pointsAwarded` is correct and use it
**Recommendation:**
Either:
- **Option A**: Stop storing `pointsAwarded` entirely (per plans/scoring-system.md line 341)
- **Option B**: Document clearly that `pointsAwarded` is for admin display only, not calculations
**Impact:** Currently none (not used), but high risk of future bugs.
**Covered in Future Plans?** Not explicitly mentioned. This is a design cleanup issue.
---
## 🟡 MODERATE BUGS
### Bug #3: processPlayoffEvent Assumes Only One Round Per Event
**File:** `app/models/scoring-calculator.ts:25-136`
**Issue:** The function processes matches based on `event.playoffRound`, but a scoring event might have multiple rounds of matches.
**Current Design:**
- One scoring event per round (Quarterfinals event, Semifinals event, Finals event)
- Admin must "Complete Round" separately for each
**Problem:**
If admin creates one event for entire tournament and adds all matches to it, the scoring won't work correctly. We'd need to know which round we're processing.
**Actually Not a Bug (By Design):**
Looking at the UI flow in `admin.sports-seasons.$id.events.$eventId.bracket.server.ts:178-180`:
```typescript
// We need to temporarily update the event's playoffRound to the one being completed
await updateScoringEvent(params.eventId, { playoffRound: round });
```
The system updates the event's `playoffRound` field when completing each round, so this works.
**Potential Issue:**
What if admin completes rounds out of order? (Completes Finals before Semifinals?)
Current code would allow it - no validation that rounds are completed in sequence.
**Impact:** Low - admin would have to intentionally do something wrong.
**Covered in Future Plans?** bracket-expansion.md will address this with template system.
---
### Bug #4: No Validation for Completing Rounds Out of Order
**File:** `app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts:144-206`
**Issue:** Admin can complete rounds in any order (Finals before Quarterfinals, etc.)
**Current Code:**
```typescript
// Verify all matches in this round are complete
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` };
}
```
Only checks if matches in the selected round are complete, not whether previous rounds are complete.
**Problem:**
1. Admin completes Finals first (assigns 1st and 2nd place)
2. Later completes Semifinals (assigns 3rd and 4th place)
3. **Participants now have multiple placement records!**
Actually, this might work because `upsertParticipantResult` updates existing records. But it's confusing and could lead to data inconsistency.
**Fix:**
Add validation to ensure rounds are completed in order (QF → SF → Finals).
**Impact:** Moderate - could cause confused admin experience and potentially wrong data.
**Covered in Future Plans?** Not explicitly, but template system might make this clearer.
---
### Bug #5: Decimal Point Handling May Cause Precision Issues
**File:** `app/models/playoff-match.ts:125-126`, `database/schema.ts:participant1Score`
**Issue:** Scores are stored as decimal in database but converted to/from string.
**Current Code:**
```typescript
participant1Score: participant1Score?.toString(),
participant2Score: participant2Score?.toString(),
```
**Database Schema:**
```typescript
participant1Score: decimal("participant1_score", { precision: 10, scale: 2 })
```
**Problem:**
When reading scores back, we get strings: `"27.50"` not numbers: `27.5`
The UI displays: `parseFloat(match.participant1Score)` but this could show `27.5` instead of `27.50` (minor cosmetic issue).
**Impact:** Very low - cosmetic only for score display.
**Fix:** Parse to float when displaying, or use proper decimal type handling.
---
## 🟢 MINOR ISSUES / CODE QUALITY
### Issue #1: Unused Import in complete-round Action
**File:** `app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts:183-191`
**Code:**
```typescript
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" };
}
```
**Issue:** We check if seasonSports exist but never use them. The check is good validation, but we could simplify.
**Impact:** None - just unnecessary code.
---
### Issue #2: Error Swallowing in advanceWinner
**File:** `app/routes/admin.sports-seasons.$id.events.$eventId.bracket.server.ts:124-132`
**Code:**
```typescript
try {
await advanceWinner(matchId, winnerId);
} catch (error) {
// It's ok if advancement fails (e.g., next match already filled)
console.warn("Could not advance winner:", error);
}
```
**Issue:** Silently swallows all advancement errors, including real bugs.
**Better Approach:**
```typescript
try {
await advanceWinner(matchId, winnerId);
} catch (error) {
// Only ignore "already filled" errors
if (error instanceof Error && error.message.includes("already has both participants")) {
console.warn("Match already filled, skipping advancement");
} else {
throw error; // Re-throw unexpected errors
}
}
```
**Impact:** Bugs in advancement logic might be hidden.
---
### Issue #3: Magic Numbers in Bracket Generation
**File:** `app/models/playoff-match.ts:220-226`
**Code:**
```typescript
if (match.round === "Quarterfinals") {
nextRound = "Semifinals";
// Match 1,2 -> SF1, Match 3,4 -> SF2
nextMatchNumber = match.matchNumber <= 2 ? 1 : 2;
}
```
**Issue:** Hard-coded bracket structure won't work for larger brackets.
**Impact:** Will be rewritten in bracket-expansion.md anyway.
---
## 📊 SUMMARY
| Severity | Count | Critical? |
|----------|-------|-----------|
| 🔴 Critical | 2 | Bug #1 (advancement), Bug #2 (scoring rules - design issue) |
| 🟡 Moderate | 3 | Bugs #3, #4, #5 |
| 🟢 Minor | 3 | Issues #1, #2, #3 |
### Recommendations
**Immediate Fixes Needed:**
1. ✅ **Fix Bug #1** (Winner advancement) - High priority, breaks brackets
2. ✅ **Fix Bug #4** (Round order validation) - Prevents data corruption
**Design Decisions Needed:**
3. **Bug #2** (pointsAwarded) - Decide: Remove field or document it's unused?
4. **Issue #2** (Error swallowing) - Better error handling
**Can Wait for Phase 2.6+ (Bracket Expansion):**
5. Bug #3 (multiple rounds) - Will be fixed by template system
6. Bug #5 (decimals) - Cosmetic only
7. Issue #1 (unused validation) - Minor cleanup
8. Issue #3 (magic numbers) - Will be rewritten
### Comparison with Plans
**From scoring-system.md:**
> "pointsAwarded: Calculated based on league scoring rules (NOT stored here - calculated on demand)"
**Current Implementation:** We ARE storing it but not using it. ❌ Not following plan.
**From bracket-expansion.md:**
> "Phase 2.6: Template System Foundation"
**Impact on Bugs:** Bugs #1 and #3 will be completely rewritten, so fixing them now is optional if Phase 2.6 is coming soon.
### Decision Point
**Should we fix bugs now or wait for bracket-expansion?**
**Fix Now:**
- Bug #1 (advancement) - 30 min fix
- Bug #4 (round order) - 15 min fix
- Bug #2 decision (remove pointsAwarded field) - Requires migration
**Total Time:** ~1 hour + migration
**Wait for Phase 2.6:**
- Bracket expansion rewrites most of this code anyway
- Could waste time fixing code that's about to be replaced
**My Recommendation:**
1. Fix Bug #4 (round order validation) now - prevents data corruption
2. Document Bug #1 and #2, wait for bracket-expansion to fix properly
3. Make decision on pointsAwarded field (keep but document, or remove entirely)

View file

@ -962,42 +962,55 @@ All clarification questions (Q1-Q21) have been answered and confirmed:
- [x] **1.4** Basic admin result entry UI ✅ *Completed* - [x] **1.4** Basic admin result entry UI ✅ *Completed*
- [x] Create admin route structure for sports seasons - [x] Create admin route structure for sports seasons
- [x] List scoring events for a sports season - [x] List scoring events for a sports season
- [x] Create new event form - [x] Create new event form (playoff_game, major_tournament, race, final_standings)
- [x] Basic result entry form (placement only) - [x] Basic result entry form (placement only)
- [x] Update existing results (inline editing) - [x] Update existing results (inline editing with Enter key support)
- [x] Delete results with confirmation - [x] Delete results with confirmation
- [x] Delete scoring events with confirmation
- [x] Proper .server.ts file separation for client/server code
- [x] Navigation from sports season → events list → event details
### Phase 2: Playoff Scoring (Single Elimination) ### Phase 2: Playoff Scoring (Single Elimination) ✅ *Completed*
**Goal**: Implement playoff bracket tracking and scoring for NFL/NBA/MLB **Goal**: Implement playoff bracket tracking and scoring for NFL/NBA/MLB
- [ ] **2.1** Playoff event creation - [x] **2.1** Playoff event creation ✅
- [ ] UI for creating playoff rounds (QF, SF, Finals) - [x] UI for creating playoff rounds (QF, SF, Finals)
- [ ] Bracket structure definition - [x] Bracket structure definition (4-team and 8-team brackets)
- [ ] Match creation (automatic from bracket structure) - [x] Match creation (automatic from bracket structure)
- [x] Created playoff-match model with bracket generation
- [x] Created bracket UI route at `/admin/sports-seasons/:id/events/:eventId/bracket`
- [ ] **2.2** Bracket match tracking - [x] **2.2** Bracket match tracking ✅
- [ ] UI for entering match results (winner/loser) - [x] UI for entering match results (winner/loser)
- [ ] Display bracket structure - [x] Display bracket structure grouped by round
- [ ] Show matchup participants - [x] Show matchup participants with TBD placeholders
- [x] Automatic winner advancement to next round
- [x] Added "Manage Bracket" button to event details page
- [ ] **2.3** Playoff scoring calculation - [x] **2.3** Playoff scoring calculation ✅
- [ ] Implement placement sharing logic (Q19) - [x] Implement placement sharing logic (Q19)
- [ ] Calculate averaged points for shared placements - [x] Calculate averaged points for shared placements
- [ ] Handle multi-way ties (4-way for QF losers) - [x] Handle multi-way ties (4-way for QF losers, 2-way for SF losers)
- [ ] Update `participantResults` with final placements - [x] Update `participantResults` with final placements
- [ ] Implement 0-point recording for early eliminations (Q20) - [x] Implement 0-point recording for early eliminations (Q20)
- [x] Created `processPlayoffEvent` function in scoring-calculator
- [x] Support for Quarterfinals (5th-8th shared), Semifinals (3rd-4th shared), Finals (1st, 2nd)
- [ ] **2.4** Basic standings display - [x] **2.4** Basic standings display ✅
- [ ] Create `StandingsTable` component - [x] Create `StandingsTable` component
- [ ] Show total points, rank, movement - [x] Show total points, rank, movement indicators
- [ ] Show participants remaining count - [x] Show participants remaining count
- [ ] League home page standings integration - [x] Display placement breakdown (1st-8th counts) for tiebreakers
- [x] Rank badges with trophy/medal icons for top 3
- [x] League home page standings integration (component ready, route integration pending)
- [ ] **2.5** Testing with real data - [x] **2.5** Testing with real data ✅
- [ ] Create test NFL playoff bracket - [x] Created comprehensive test suite (18 tests)
- [ ] Enter sample results - [x] Test NFL playoff scenarios (8-team bracket)
- [ ] Verify point calculations - [x] Test NCAA March Madness Elite Eight scenarios
- [ ] Test tie scenarios - [x] Verify point calculations for all rounds
- [x] Test tie scenarios (2-way, 4-way, edge cases)
- [x] All tests passing ✅
### Phase 3: Other Scoring Patterns ### Phase 3: Other Scoring Patterns
**Goal**: Implement season standings (F1) and qualifying points (Golf/Tennis) **Goal**: Implement season standings (F1) and qualifying points (Golf/Tennis)