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:
parent
60ba3d4a6e
commit
a3b8fa6309
22 changed files with 7493 additions and 76 deletions
166
app/components/StandingsTable.tsx
Normal file
166
app/components/StandingsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
207
app/models/__tests__/scoring-calculator.test.ts
Normal file
207
app/models/__tests__/scoring-calculator.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -101,20 +101,8 @@ export async function deleteParticipantResultsBySportsSeasonId(
|
|||
}
|
||||
|
||||
/**
|
||||
* Calculate points awarded based on final position
|
||||
* 1st: 80, 2nd: 50, 3rd-4th: 30, 5th-8th: 20, 9th+: 0
|
||||
*/
|
||||
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
|
||||
* Set result for a participant in a sports season
|
||||
* Points are calculated on-demand based on each fantasy league's scoring rules
|
||||
*/
|
||||
export async function setParticipantResult(
|
||||
participantId: string,
|
||||
|
|
@ -124,7 +112,6 @@ export async function setParticipantResult(
|
|||
notes?: string
|
||||
): Promise<ParticipantResult> {
|
||||
const db = database();
|
||||
const pointsAwarded = calculatePointsFromPosition(finalPosition);
|
||||
|
||||
// Check if result already exists
|
||||
const existing = await db.query.participantResults.findFirst({
|
||||
|
|
@ -138,7 +125,6 @@ export async function setParticipantResult(
|
|||
// Update existing result
|
||||
return await updateParticipantResult(existing.id, {
|
||||
finalPosition,
|
||||
pointsAwarded,
|
||||
qualifyingPoints: qualifyingPoints?.toString(),
|
||||
notes,
|
||||
});
|
||||
|
|
@ -148,7 +134,6 @@ export async function setParticipantResult(
|
|||
participantId,
|
||||
sportsSeasonId,
|
||||
finalPosition,
|
||||
pointsAwarded,
|
||||
qualifyingPoints: qualifyingPoints?.toString(),
|
||||
notes,
|
||||
});
|
||||
|
|
|
|||
251
app/models/playoff-match.ts
Normal file
251
app/models/playoff-match.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -16,7 +16,11 @@ export type ScoringPattern =
|
|||
|
||||
/**
|
||||
* 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(
|
||||
eventId: string,
|
||||
|
|
@ -24,14 +28,134 @@ export async function processPlayoffEvent(
|
|||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
|
||||
// TODO: Implement in Phase 2
|
||||
// 1. Get event and determine round (QF, SF, Finals)
|
||||
// 2. Get matches and determine winners/losers
|
||||
// 3. Assign shared placements based on elimination round
|
||||
// 4. Update participantResults with final placements
|
||||
// 5. Trigger recalculation for affected leagues
|
||||
// Get the event
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.id, eventId),
|
||||
with: {
|
||||
sportsSeason: {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { database } from "~/database/context";
|
|||
import * as schema from "~/database/schema";
|
||||
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 {
|
||||
sportsSeasonId: string;
|
||||
|
|
@ -16,6 +16,7 @@ export interface CreateScoringEventData {
|
|||
export interface UpdateScoringEventData {
|
||||
name?: string;
|
||||
eventDate?: Date;
|
||||
playoffRound?: string;
|
||||
isComplete?: boolean;
|
||||
completedAt?: Date;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,10 @@ export default [
|
|||
"sports-seasons/:id/events/:eventId",
|
||||
"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("templates", "routes/admin.templates.tsx"),
|
||||
route("templates/new", "routes/admin.templates.new.tsx"),
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
}
|
||||
278
app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx
Normal file
278
app/routes/admin.sports-seasons.$id.events.$eventId.bracket.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ import {
|
|||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
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 {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -53,11 +53,9 @@ export default function EventResults({
|
|||
const getEventTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case "playoff_game":
|
||||
return "Playoff Game";
|
||||
return "Bracket";
|
||||
case "major_tournament":
|
||||
return "Major Tournament";
|
||||
case "race":
|
||||
return "Race";
|
||||
case "final_standings":
|
||||
return "Final Standings";
|
||||
default:
|
||||
|
|
@ -84,6 +82,15 @@ export default function EventResults({
|
|||
{event.playoffRound && ` • ${event.playoffRound}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{event.eventType === "playoff_game" && (
|
||||
<Button variant="outline" asChild>
|
||||
<Link to={`/admin/sports-seasons/${sportsSeason.id}/events/${event.id}/bracket`}>
|
||||
<Brackets className="mr-2 h-4 w-4" />
|
||||
Manage Bracket
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{event.isComplete ? (
|
||||
<Badge variant="default" className="bg-green-500">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
|
|
@ -94,6 +101,7 @@ export default function EventResults({
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{actionData?.error && (
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
if (
|
||||
eventType !== "playoff_game" &&
|
||||
eventType !== "major_tournament" &&
|
||||
eventType !== "race" &&
|
||||
eventType !== "final_standings"
|
||||
) {
|
||||
return { error: "Invalid event type" };
|
||||
|
|
@ -67,7 +66,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
const eventData: CreateScoringEventData = {
|
||||
sportsSeasonId: params.id,
|
||||
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,
|
||||
playoffRound: typeof playoffRound === "string" && playoffRound ? playoffRound : undefined,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -33,11 +33,9 @@ export default function SportsSeasonEvents({
|
|||
const getEventTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case "playoff_game":
|
||||
return "Playoff Game";
|
||||
return "Bracket";
|
||||
case "major_tournament":
|
||||
return "Major Tournament";
|
||||
case "race":
|
||||
return "Race";
|
||||
case "final_standings":
|
||||
return "Final Standings";
|
||||
default:
|
||||
|
|
@ -77,7 +75,7 @@ export default function SportsSeasonEvents({
|
|||
<CardHeader>
|
||||
<CardTitle>Create New Event</CardTitle>
|
||||
<CardDescription>
|
||||
Add a new scoring event (game, tournament, race, etc.)
|
||||
Add a new scoring event (bracket, tournament, standings, etc.)
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -101,9 +99,8 @@ export default function SportsSeasonEvents({
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="playoff_game">Playoff Game</SelectItem>
|
||||
<SelectItem value="playoff_game">Bracket</SelectItem>
|
||||
<SelectItem value="major_tournament">Major Tournament</SelectItem>
|
||||
<SelectItem value="race">Race</SelectItem>
|
||||
<SelectItem value="final_standings">Final Standings</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import {
|
|||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} 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) {
|
||||
const sportsSeason = await findSportsSeasonById(params.id);
|
||||
|
|
@ -266,6 +266,31 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
|||
</CardContent>
|
||||
</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">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ export const scoringPatternEnum = pgEnum("scoring_pattern", [
|
|||
export const eventTypeEnum = pgEnum("event_type", [
|
||||
"playoff_game",
|
||||
"major_tournament",
|
||||
"race",
|
||||
"final_standings",
|
||||
]);
|
||||
|
||||
|
|
@ -288,7 +287,6 @@ export const participantResults = pgTable("participant_results", {
|
|||
.notNull()
|
||||
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
|
||||
finalPosition: integer("final_position"),
|
||||
pointsAwarded: integer("points_awarded").notNull().default(0),
|
||||
qualifyingPoints: decimal("qualifying_points", { precision: 10, scale: 2 }),
|
||||
notes: text("notes"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
|
|
|
|||
4
drizzle/0021_fair_ser_duncan.sql
Normal file
4
drizzle/0021_fair_ser_duncan.sql
Normal 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";
|
||||
1
drizzle/0022_shiny_mother_askani.sql
Normal file
1
drizzle/0022_shiny_mother_askani.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "participant_results" DROP COLUMN IF EXISTS "points_awarded";
|
||||
2706
drizzle/meta/0021_snapshot.json
Normal file
2706
drizzle/meta/0021_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
2699
drizzle/meta/0022_snapshot.json
Normal file
2699
drizzle/meta/0022_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -148,6 +148,20 @@
|
|||
"when": 1761719248881,
|
||||
"tag": "0020_slim_spacker_dave",
|
||||
"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
345
plans/bracket-expansion.md
Normal 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
337
plans/phase2-bugs-review.md
Normal 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)
|
||||
|
|
@ -962,42 +962,55 @@ All clarification questions (Q1-Q21) have been answered and confirmed:
|
|||
- [x] **1.4** Basic admin result entry UI ✅ *Completed*
|
||||
- [x] Create admin route structure for sports seasons
|
||||
- [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] Update existing results (inline editing)
|
||||
- [x] Update existing results (inline editing with Enter key support)
|
||||
- [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
|
||||
|
||||
- [ ] **2.1** Playoff event creation
|
||||
- [ ] UI for creating playoff rounds (QF, SF, Finals)
|
||||
- [ ] Bracket structure definition
|
||||
- [ ] Match creation (automatic from bracket structure)
|
||||
- [x] **2.1** Playoff event creation ✅
|
||||
- [x] UI for creating playoff rounds (QF, SF, Finals)
|
||||
- [x] Bracket structure definition (4-team and 8-team brackets)
|
||||
- [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
|
||||
- [ ] UI for entering match results (winner/loser)
|
||||
- [ ] Display bracket structure
|
||||
- [ ] Show matchup participants
|
||||
- [x] **2.2** Bracket match tracking ✅
|
||||
- [x] UI for entering match results (winner/loser)
|
||||
- [x] Display bracket structure grouped by round
|
||||
- [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
|
||||
- [ ] Implement placement sharing logic (Q19)
|
||||
- [ ] Calculate averaged points for shared placements
|
||||
- [ ] Handle multi-way ties (4-way for QF losers)
|
||||
- [ ] Update `participantResults` with final placements
|
||||
- [ ] Implement 0-point recording for early eliminations (Q20)
|
||||
- [x] **2.3** Playoff scoring calculation ✅
|
||||
- [x] Implement placement sharing logic (Q19)
|
||||
- [x] Calculate averaged points for shared placements
|
||||
- [x] Handle multi-way ties (4-way for QF losers, 2-way for SF losers)
|
||||
- [x] Update `participantResults` with final placements
|
||||
- [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
|
||||
- [ ] Create `StandingsTable` component
|
||||
- [ ] Show total points, rank, movement
|
||||
- [ ] Show participants remaining count
|
||||
- [ ] League home page standings integration
|
||||
- [x] **2.4** Basic standings display ✅
|
||||
- [x] Create `StandingsTable` component
|
||||
- [x] Show total points, rank, movement indicators
|
||||
- [x] Show participants remaining count
|
||||
- [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
|
||||
- [ ] Create test NFL playoff bracket
|
||||
- [ ] Enter sample results
|
||||
- [ ] Verify point calculations
|
||||
- [ ] Test tie scenarios
|
||||
- [x] **2.5** Testing with real data ✅
|
||||
- [x] Created comprehensive test suite (18 tests)
|
||||
- [x] Test NFL playoff scenarios (8-team bracket)
|
||||
- [x] Test NCAA March Madness Elite Eight 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
|
||||
**Goal**: Implement season standings (F1) and qualifying points (Golf/Tennis)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue