Refactor code structure for improved readability and maintainability
This commit is contained in:
parent
a3b8fa6309
commit
da1a17f2d3
12 changed files with 3588 additions and 36 deletions
348
app/lib/bracket-templates.ts
Normal file
348
app/lib/bracket-templates.ts
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
/**
|
||||
* Bracket Template System
|
||||
*
|
||||
* Defines pre-configured tournament bracket structures for various sports.
|
||||
* Templates specify rounds, match counts, and which rounds contribute to fantasy scoring.
|
||||
*/
|
||||
|
||||
export interface BracketRound {
|
||||
/** Display name for this round (e.g., "First Four", "Elite Eight") */
|
||||
name: string;
|
||||
/** Number of matches in this round */
|
||||
matchCount: number;
|
||||
/** Name of the round that winners advance to (null for championship) */
|
||||
feedsInto: string | null;
|
||||
/** Whether this round affects fantasy points (false = 0 points per Q20) */
|
||||
isScoring: boolean;
|
||||
}
|
||||
|
||||
export interface BracketTemplate {
|
||||
/** Unique identifier for this template */
|
||||
id: string;
|
||||
/** Human-readable name */
|
||||
name: string;
|
||||
/** Total number of teams in the bracket */
|
||||
totalTeams: number;
|
||||
/** Ordered list of rounds from earliest to championship */
|
||||
rounds: BracketRound[];
|
||||
/** Round name where fantasy scoring begins */
|
||||
scoringStartsAtRound: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple 4-team bracket
|
||||
* Semifinals → Finals
|
||||
* All rounds score fantasy points
|
||||
*/
|
||||
export const SIMPLE_4: BracketTemplate = {
|
||||
id: "simple_4",
|
||||
name: "Simple 4-Team Bracket",
|
||||
totalTeams: 4,
|
||||
scoringStartsAtRound: "Semifinals",
|
||||
rounds: [
|
||||
{
|
||||
name: "Semifinals",
|
||||
matchCount: 2,
|
||||
feedsInto: "Finals",
|
||||
isScoring: true, // Losers share 3rd-4th
|
||||
},
|
||||
{
|
||||
name: "Finals",
|
||||
matchCount: 1,
|
||||
feedsInto: null,
|
||||
isScoring: true, // Winner 1st, Loser 2nd
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple 8-team bracket
|
||||
* Quarterfinals → Semifinals → Finals
|
||||
* All rounds score fantasy points
|
||||
*/
|
||||
export const SIMPLE_8: BracketTemplate = {
|
||||
id: "simple_8",
|
||||
name: "Simple 8-Team Bracket",
|
||||
totalTeams: 8,
|
||||
scoringStartsAtRound: "Quarterfinals",
|
||||
rounds: [
|
||||
{
|
||||
name: "Quarterfinals",
|
||||
matchCount: 4,
|
||||
feedsInto: "Semifinals",
|
||||
isScoring: true, // Losers share 5th-8th
|
||||
},
|
||||
{
|
||||
name: "Semifinals",
|
||||
matchCount: 2,
|
||||
feedsInto: "Finals",
|
||||
isScoring: true, // Losers share 3rd-4th
|
||||
},
|
||||
{
|
||||
name: "Finals",
|
||||
matchCount: 1,
|
||||
feedsInto: null,
|
||||
isScoring: true, // Winner 1st, Loser 2nd
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple 16-team bracket
|
||||
* Round of 16 → Quarterfinals → Semifinals → Finals
|
||||
* Only Quarterfinals and beyond score points (top 8)
|
||||
*/
|
||||
export const SIMPLE_16: BracketTemplate = {
|
||||
id: "simple_16",
|
||||
name: "Simple 16-Team Bracket",
|
||||
totalTeams: 16,
|
||||
scoringStartsAtRound: "Quarterfinals",
|
||||
rounds: [
|
||||
{
|
||||
name: "Round of 16",
|
||||
matchCount: 8,
|
||||
feedsInto: "Quarterfinals",
|
||||
isScoring: false, // Early elimination = 0 points
|
||||
},
|
||||
{
|
||||
name: "Quarterfinals",
|
||||
matchCount: 4,
|
||||
feedsInto: "Semifinals",
|
||||
isScoring: true, // Losers share 5th-8th
|
||||
},
|
||||
{
|
||||
name: "Semifinals",
|
||||
matchCount: 2,
|
||||
feedsInto: "Finals",
|
||||
isScoring: true, // Losers share 3rd-4th
|
||||
},
|
||||
{
|
||||
name: "Finals",
|
||||
matchCount: 1,
|
||||
feedsInto: null,
|
||||
isScoring: true, // Winner 1st, Loser 2nd
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple 32-team bracket
|
||||
* Round of 32 → Round of 16 → Quarterfinals → Semifinals → Finals
|
||||
* Only Quarterfinals and beyond score points (top 8)
|
||||
*/
|
||||
export const SIMPLE_32: BracketTemplate = {
|
||||
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, // Early elimination = 0 points
|
||||
},
|
||||
{
|
||||
name: "Round of 16",
|
||||
matchCount: 8,
|
||||
feedsInto: "Quarterfinals",
|
||||
isScoring: false, // Early elimination = 0 points
|
||||
},
|
||||
{
|
||||
name: "Quarterfinals",
|
||||
matchCount: 4,
|
||||
feedsInto: "Semifinals",
|
||||
isScoring: true, // Losers share 5th-8th
|
||||
},
|
||||
{
|
||||
name: "Semifinals",
|
||||
matchCount: 2,
|
||||
feedsInto: "Finals",
|
||||
isScoring: true, // Losers share 3rd-4th
|
||||
},
|
||||
{
|
||||
name: "Finals",
|
||||
matchCount: 1,
|
||||
feedsInto: null,
|
||||
isScoring: true, // Winner 1st, Loser 2nd
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* NCAA March Madness (68 teams)
|
||||
* First Four (play-in) → Round of 64 → Round of 32 → Sweet Sixteen → Elite Eight → Final Four → Championship
|
||||
* Only Elite Eight and beyond score points per Q18
|
||||
*/
|
||||
export const NCAA_68: BracketTemplate = {
|
||||
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, // Play-in games don't score
|
||||
},
|
||||
{
|
||||
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, // Losers share 5th-8th
|
||||
},
|
||||
{
|
||||
name: "Final Four",
|
||||
matchCount: 2,
|
||||
feedsInto: "Championship",
|
||||
isScoring: true, // Losers share 3rd-4th
|
||||
},
|
||||
{
|
||||
name: "Championship",
|
||||
matchCount: 1,
|
||||
feedsInto: null,
|
||||
isScoring: true, // Winner 1st, Loser 2nd
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* NFL Playoffs (14 teams)
|
||||
* Wild Card → Divisional → Conference Championship → Super Bowl
|
||||
* Top 2 seeds get byes (skip Wild Card)
|
||||
* Scoring starts at Divisional (top 8)
|
||||
*/
|
||||
export const NFL_14: BracketTemplate = {
|
||||
id: "nfl_14",
|
||||
name: "NFL Playoffs (14 teams)",
|
||||
totalTeams: 14,
|
||||
scoringStartsAtRound: "Divisional",
|
||||
rounds: [
|
||||
{
|
||||
name: "Wild Card",
|
||||
matchCount: 6,
|
||||
feedsInto: "Divisional",
|
||||
isScoring: false, // 12 teams play, 2 have byes
|
||||
},
|
||||
{
|
||||
name: "Divisional",
|
||||
matchCount: 4,
|
||||
feedsInto: "Conference Championship",
|
||||
isScoring: true, // Quarterfinals - Losers share 5th-8th
|
||||
},
|
||||
{
|
||||
name: "Conference Championship",
|
||||
matchCount: 2,
|
||||
feedsInto: "Super Bowl",
|
||||
isScoring: true, // Semifinals - Losers share 3rd-4th
|
||||
},
|
||||
{
|
||||
name: "Super Bowl",
|
||||
matchCount: 1,
|
||||
feedsInto: null,
|
||||
isScoring: true, // Finals - Winner 1st, Loser 2nd
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* NBA Playoffs (16 teams)
|
||||
* First Round → Conference Semifinals → Conference Finals → NBA Finals
|
||||
* Scoring starts at Conference Semifinals (top 8)
|
||||
*/
|
||||
export const NBA_16: BracketTemplate = {
|
||||
id: "nba_16",
|
||||
name: "NBA Playoffs (16 teams)",
|
||||
totalTeams: 16,
|
||||
scoringStartsAtRound: "Conference Semifinals",
|
||||
rounds: [
|
||||
{
|
||||
name: "First Round",
|
||||
matchCount: 8,
|
||||
feedsInto: "Conference Semifinals",
|
||||
isScoring: false,
|
||||
},
|
||||
{
|
||||
name: "Conference Semifinals",
|
||||
matchCount: 4,
|
||||
feedsInto: "Conference Finals",
|
||||
isScoring: true, // Quarterfinals - Losers share 5th-8th
|
||||
},
|
||||
{
|
||||
name: "Conference Finals",
|
||||
matchCount: 2,
|
||||
feedsInto: "NBA Finals",
|
||||
isScoring: true, // Semifinals - Losers share 3rd-4th
|
||||
},
|
||||
{
|
||||
name: "NBA Finals",
|
||||
matchCount: 1,
|
||||
feedsInto: null,
|
||||
isScoring: true, // Finals - Winner 1st, Loser 2nd
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* All available bracket templates
|
||||
*/
|
||||
export const BRACKET_TEMPLATES: Record<string, BracketTemplate> = {
|
||||
simple_4: SIMPLE_4,
|
||||
simple_8: SIMPLE_8,
|
||||
simple_16: SIMPLE_16,
|
||||
simple_32: SIMPLE_32,
|
||||
ncaa_68: NCAA_68,
|
||||
nfl_14: NFL_14,
|
||||
nba_16: NBA_16,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a bracket template by ID
|
||||
*/
|
||||
export function getBracketTemplate(id: string): BracketTemplate | undefined {
|
||||
return BRACKET_TEMPLATES[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all bracket templates as an array
|
||||
*/
|
||||
export function getAllBracketTemplates(): BracketTemplate[] {
|
||||
return Object.values(BRACKET_TEMPLATES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to determine scoring round type based on match count in scoring rounds
|
||||
* Used for determining placement sharing (5-8th, 3-4th, 1-2nd)
|
||||
*/
|
||||
export function getScoringRoundType(
|
||||
roundName: string,
|
||||
template: BracketTemplate
|
||||
): "quarterfinals" | "semifinals" | "finals" | null {
|
||||
const round = template.rounds.find((r) => r.name === roundName);
|
||||
if (!round || !round.isScoring) return null;
|
||||
|
||||
// Determine type by match count in scoring round
|
||||
if (round.matchCount === 4) return "quarterfinals"; // 8 teams, losers share 5-8th
|
||||
if (round.matchCount === 2) return "semifinals"; // 4 teams, losers share 3-4th
|
||||
if (round.matchCount === 1) return "finals"; // 2 teams, 1st and 2nd
|
||||
return null;
|
||||
}
|
||||
202
app/models/__tests__/bracket-templates.test.ts
Normal file
202
app/models/__tests__/bracket-templates.test.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import {
|
||||
SIMPLE_4,
|
||||
SIMPLE_8,
|
||||
SIMPLE_16,
|
||||
SIMPLE_32,
|
||||
NCAA_68,
|
||||
NFL_14,
|
||||
NBA_16,
|
||||
getBracketTemplate,
|
||||
getScoringRoundType,
|
||||
} from "~/lib/bracket-templates";
|
||||
|
||||
describe("Bracket Templates", () => {
|
||||
describe("Template Structure", () => {
|
||||
it("SIMPLE_4 has correct structure", () => {
|
||||
expect(SIMPLE_4.id).toBe("simple_4");
|
||||
expect(SIMPLE_4.totalTeams).toBe(4);
|
||||
expect(SIMPLE_4.rounds).toHaveLength(2);
|
||||
expect(SIMPLE_4.scoringStartsAtRound).toBe("Semifinals");
|
||||
expect(SIMPLE_4.rounds.every((r) => r.isScoring)).toBe(true);
|
||||
});
|
||||
|
||||
it("SIMPLE_8 has correct structure", () => {
|
||||
expect(SIMPLE_8.id).toBe("simple_8");
|
||||
expect(SIMPLE_8.totalTeams).toBe(8);
|
||||
expect(SIMPLE_8.rounds).toHaveLength(3);
|
||||
expect(SIMPLE_8.scoringStartsAtRound).toBe("Quarterfinals");
|
||||
expect(SIMPLE_8.rounds.every((r) => r.isScoring)).toBe(true);
|
||||
});
|
||||
|
||||
it("SIMPLE_16 has correct structure", () => {
|
||||
expect(SIMPLE_16.id).toBe("simple_16");
|
||||
expect(SIMPLE_16.totalTeams).toBe(16);
|
||||
expect(SIMPLE_16.rounds).toHaveLength(4);
|
||||
expect(SIMPLE_16.scoringStartsAtRound).toBe("Quarterfinals");
|
||||
|
||||
// First round doesn't score
|
||||
expect(SIMPLE_16.rounds[0].isScoring).toBe(false);
|
||||
expect(SIMPLE_16.rounds[0].name).toBe("Round of 16");
|
||||
|
||||
// Quarterfinals and beyond score
|
||||
expect(SIMPLE_16.rounds[1].isScoring).toBe(true);
|
||||
expect(SIMPLE_16.rounds[2].isScoring).toBe(true);
|
||||
expect(SIMPLE_16.rounds[3].isScoring).toBe(true);
|
||||
});
|
||||
|
||||
it("SIMPLE_32 has correct structure", () => {
|
||||
expect(SIMPLE_32.id).toBe("simple_32");
|
||||
expect(SIMPLE_32.totalTeams).toBe(32);
|
||||
expect(SIMPLE_32.rounds).toHaveLength(5);
|
||||
expect(SIMPLE_32.scoringStartsAtRound).toBe("Quarterfinals");
|
||||
|
||||
// First two rounds don't score
|
||||
expect(SIMPLE_32.rounds[0].isScoring).toBe(false);
|
||||
expect(SIMPLE_32.rounds[1].isScoring).toBe(false);
|
||||
|
||||
// Quarterfinals and beyond score
|
||||
expect(SIMPLE_32.rounds[2].isScoring).toBe(true);
|
||||
expect(SIMPLE_32.rounds[3].isScoring).toBe(true);
|
||||
expect(SIMPLE_32.rounds[4].isScoring).toBe(true);
|
||||
});
|
||||
|
||||
it("NCAA_68 has correct structure", () => {
|
||||
expect(NCAA_68.id).toBe("ncaa_68");
|
||||
expect(NCAA_68.totalTeams).toBe(68);
|
||||
expect(NCAA_68.rounds).toHaveLength(7);
|
||||
expect(NCAA_68.scoringStartsAtRound).toBe("Elite Eight");
|
||||
|
||||
// First Four through Sweet Sixteen don't score
|
||||
expect(NCAA_68.rounds[0].name).toBe("First Four");
|
||||
expect(NCAA_68.rounds[0].isScoring).toBe(false);
|
||||
expect(NCAA_68.rounds[1].name).toBe("Round of 64");
|
||||
expect(NCAA_68.rounds[1].isScoring).toBe(false);
|
||||
expect(NCAA_68.rounds[2].name).toBe("Round of 32");
|
||||
expect(NCAA_68.rounds[2].isScoring).toBe(false);
|
||||
expect(NCAA_68.rounds[3].name).toBe("Sweet Sixteen");
|
||||
expect(NCAA_68.rounds[3].isScoring).toBe(false);
|
||||
|
||||
// Elite Eight and beyond score
|
||||
expect(NCAA_68.rounds[4].name).toBe("Elite Eight");
|
||||
expect(NCAA_68.rounds[4].isScoring).toBe(true);
|
||||
expect(NCAA_68.rounds[5].name).toBe("Final Four");
|
||||
expect(NCAA_68.rounds[5].isScoring).toBe(true);
|
||||
expect(NCAA_68.rounds[6].name).toBe("Championship");
|
||||
expect(NCAA_68.rounds[6].isScoring).toBe(true);
|
||||
});
|
||||
|
||||
it("NFL_14 has correct structure", () => {
|
||||
expect(NFL_14.id).toBe("nfl_14");
|
||||
expect(NFL_14.totalTeams).toBe(14);
|
||||
expect(NFL_14.rounds).toHaveLength(4);
|
||||
expect(NFL_14.scoringStartsAtRound).toBe("Divisional");
|
||||
|
||||
// Wild Card doesn't score
|
||||
expect(NFL_14.rounds[0].name).toBe("Wild Card");
|
||||
expect(NFL_14.rounds[0].isScoring).toBe(false);
|
||||
|
||||
// Divisional and beyond score
|
||||
expect(NFL_14.rounds[1].name).toBe("Divisional");
|
||||
expect(NFL_14.rounds[1].isScoring).toBe(true);
|
||||
expect(NFL_14.rounds[2].isScoring).toBe(true);
|
||||
expect(NFL_14.rounds[3].isScoring).toBe(true);
|
||||
});
|
||||
|
||||
it("NBA_16 has correct structure", () => {
|
||||
expect(NBA_16.id).toBe("nba_16");
|
||||
expect(NBA_16.totalTeams).toBe(16);
|
||||
expect(NBA_16.rounds).toHaveLength(4);
|
||||
expect(NBA_16.scoringStartsAtRound).toBe("Conference Semifinals");
|
||||
|
||||
// First Round doesn't score
|
||||
expect(NBA_16.rounds[0].name).toBe("First Round");
|
||||
expect(NBA_16.rounds[0].isScoring).toBe(false);
|
||||
|
||||
// Conference Semifinals and beyond score
|
||||
expect(NBA_16.rounds[1].name).toBe("Conference Semifinals");
|
||||
expect(NBA_16.rounds[1].isScoring).toBe(true);
|
||||
expect(NBA_16.rounds[2].isScoring).toBe(true);
|
||||
expect(NBA_16.rounds[3].isScoring).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Round Advancement", () => {
|
||||
it("rounds correctly feed into next rounds", () => {
|
||||
// 8-team bracket
|
||||
expect(SIMPLE_8.rounds[0].feedsInto).toBe("Semifinals");
|
||||
expect(SIMPLE_8.rounds[1].feedsInto).toBe("Finals");
|
||||
expect(SIMPLE_8.rounds[2].feedsInto).toBe(null);
|
||||
|
||||
// NCAA bracket
|
||||
expect(NCAA_68.rounds[0].feedsInto).toBe("Round of 64");
|
||||
expect(NCAA_68.rounds[1].feedsInto).toBe("Round of 32");
|
||||
expect(NCAA_68.rounds[2].feedsInto).toBe("Sweet Sixteen");
|
||||
expect(NCAA_68.rounds[3].feedsInto).toBe("Elite Eight");
|
||||
expect(NCAA_68.rounds[4].feedsInto).toBe("Final Four");
|
||||
expect(NCAA_68.rounds[5].feedsInto).toBe("Championship");
|
||||
expect(NCAA_68.rounds[6].feedsInto).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Match Counts", () => {
|
||||
it("calculates correct match counts for each round", () => {
|
||||
// 16-team bracket
|
||||
expect(SIMPLE_16.rounds[0].matchCount).toBe(8); // Round of 16
|
||||
expect(SIMPLE_16.rounds[1].matchCount).toBe(4); // QF
|
||||
expect(SIMPLE_16.rounds[2].matchCount).toBe(2); // SF
|
||||
expect(SIMPLE_16.rounds[3].matchCount).toBe(1); // Finals
|
||||
|
||||
// NCAA bracket
|
||||
expect(NCAA_68.rounds[0].matchCount).toBe(4); // First Four
|
||||
expect(NCAA_68.rounds[1].matchCount).toBe(32); // Round of 64
|
||||
expect(NCAA_68.rounds[2].matchCount).toBe(16); // Round of 32
|
||||
expect(NCAA_68.rounds[3].matchCount).toBe(8); // Sweet Sixteen
|
||||
expect(NCAA_68.rounds[4].matchCount).toBe(4); // Elite Eight
|
||||
expect(NCAA_68.rounds[5].matchCount).toBe(2); // Final Four
|
||||
expect(NCAA_68.rounds[6].matchCount).toBe(1); // Championship
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBracketTemplate", () => {
|
||||
it("returns correct template by id", () => {
|
||||
expect(getBracketTemplate("simple_4")).toBe(SIMPLE_4);
|
||||
expect(getBracketTemplate("simple_8")).toBe(SIMPLE_8);
|
||||
expect(getBracketTemplate("simple_16")).toBe(SIMPLE_16);
|
||||
expect(getBracketTemplate("ncaa_68")).toBe(NCAA_68);
|
||||
});
|
||||
|
||||
it("returns undefined for invalid id", () => {
|
||||
expect(getBracketTemplate("invalid")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getScoringRoundType", () => {
|
||||
it("identifies quarterfinals (4 matches)", () => {
|
||||
const type = getScoringRoundType("Quarterfinals", SIMPLE_8);
|
||||
expect(type).toBe("quarterfinals");
|
||||
});
|
||||
|
||||
it("identifies semifinals (2 matches)", () => {
|
||||
const type = getScoringRoundType("Semifinals", SIMPLE_8);
|
||||
expect(type).toBe("semifinals");
|
||||
});
|
||||
|
||||
it("identifies finals (1 match)", () => {
|
||||
const type = getScoringRoundType("Finals", SIMPLE_8);
|
||||
expect(type).toBe("finals");
|
||||
});
|
||||
|
||||
it("returns null for non-scoring rounds", () => {
|
||||
const type = getScoringRoundType("Round of 16", SIMPLE_16);
|
||||
expect(type).toBe(null);
|
||||
});
|
||||
|
||||
it("works with NCAA template", () => {
|
||||
expect(getScoringRoundType("Elite Eight", NCAA_68)).toBe("quarterfinals");
|
||||
expect(getScoringRoundType("Final Four", NCAA_68)).toBe("semifinals");
|
||||
expect(getScoringRoundType("Championship", NCAA_68)).toBe("finals");
|
||||
expect(getScoringRoundType("Sweet Sixteen", NCAA_68)).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { eq, and } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import type { BracketTemplate } from "~/lib/bracket-templates";
|
||||
import { getBracketTemplate } from "~/lib/bracket-templates";
|
||||
|
||||
export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
|
||||
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
|
||||
|
|
@ -249,3 +251,129 @@ export async function advanceWinner(
|
|||
// Fill the determined slot
|
||||
await updatePlayoffMatch(nextMatch.id, { [participantSlot]: winnerId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a bracket from a template
|
||||
* Phase 2.6: Template-based bracket generation
|
||||
*
|
||||
* @param eventId - The scoring event ID
|
||||
* @param templateId - The bracket template ID (e.g., "simple_16", "ncaa_68")
|
||||
* @param participantIds - Array of participant IDs to assign to the bracket (optional, can be assigned later)
|
||||
* @returns Array of created playoff matches
|
||||
*/
|
||||
export async function generateBracketFromTemplate(
|
||||
eventId: string,
|
||||
templateId: string,
|
||||
participantIds?: string[]
|
||||
): Promise<PlayoffMatch[]> {
|
||||
const template = getBracketTemplate(templateId);
|
||||
if (!template) {
|
||||
throw new Error(`Bracket template '${templateId}' not found`);
|
||||
}
|
||||
|
||||
// Validate participant count if provided
|
||||
if (participantIds && participantIds.length !== template.totalTeams) {
|
||||
throw new Error(
|
||||
`Template '${templateId}' requires ${template.totalTeams} participants, but ${participantIds.length} were provided`
|
||||
);
|
||||
}
|
||||
|
||||
const matches: NewPlayoffMatch[] = [];
|
||||
let participantIndex = 0;
|
||||
|
||||
// Generate matches for each round in the template
|
||||
for (const round of template.rounds) {
|
||||
const teamsInRound = round.matchCount * 2;
|
||||
|
||||
for (let i = 0; i < round.matchCount; i++) {
|
||||
// Determine if we should assign participants to this match
|
||||
let participant1Id: string | null = null;
|
||||
let participant2Id: string | null = null;
|
||||
|
||||
// Only assign participants to the first round if participantIds provided
|
||||
if (participantIds && round === template.rounds[0]) {
|
||||
participant1Id = participantIds[participantIndex] || null;
|
||||
participant2Id = participantIds[participantIndex + 1] || null;
|
||||
participantIndex += 2;
|
||||
}
|
||||
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
round: round.name,
|
||||
matchNumber: i + 1,
|
||||
participant1Id,
|
||||
participant2Id,
|
||||
isComplete: false,
|
||||
isScoring: round.isScoring,
|
||||
templateRound: round.name,
|
||||
seedInfo: null, // Can be set manually later for display (e.g., "1 vs 16")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return await createManyPlayoffMatches(matches);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance winner to next round using template-based logic
|
||||
* Works with any bracket template
|
||||
*/
|
||||
export async function advanceWinnerTemplate(
|
||||
matchId: string,
|
||||
winnerId: string,
|
||||
template: BracketTemplate
|
||||
): Promise<void> {
|
||||
const db = database();
|
||||
|
||||
// Get the match
|
||||
const match = await findPlayoffMatchById(matchId);
|
||||
if (!match) throw new Error("Match not found");
|
||||
|
||||
// Find current round in template
|
||||
const currentRoundIndex = template.rounds.findIndex(
|
||||
(r) => r.name === match.round
|
||||
);
|
||||
if (currentRoundIndex === -1) {
|
||||
throw new Error(`Round '${match.round}' not found in template`);
|
||||
}
|
||||
|
||||
const currentRound = template.rounds[currentRoundIndex];
|
||||
|
||||
// Check if there's a next round
|
||||
if (!currentRound.feedsInto) {
|
||||
// Championship game - no advancement needed
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRound = template.rounds.find((r) => r.name === currentRound.feedsInto);
|
||||
if (!nextRound) {
|
||||
throw new Error(`Next round '${currentRound.feedsInto}' not found in template`);
|
||||
}
|
||||
|
||||
// Determine next match number and slot
|
||||
// This uses the same logic as before: matches advance in sequential pairs
|
||||
const nextMatchNumber = Math.ceil(match.matchNumber / 2);
|
||||
const participantSlot: 'participant1Id' | 'participant2Id' =
|
||||
match.matchNumber % 2 === 1 ? 'participant1Id' : 'participant2Id';
|
||||
|
||||
// Find the next match
|
||||
const nextMatches = await findPlayoffMatchesByEventIdAndRound(
|
||||
match.scoringEventId,
|
||||
nextRound.name
|
||||
);
|
||||
const nextMatch = nextMatches.find((m) => m.matchNumber === nextMatchNumber);
|
||||
|
||||
if (!nextMatch) {
|
||||
throw new Error(
|
||||
`Next match not found: round=${nextRound.name}, matchNumber=${nextMatchNumber}`
|
||||
);
|
||||
}
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,16 +9,19 @@ export interface CreateScoringEventData {
|
|||
name: string;
|
||||
eventDate?: Date;
|
||||
eventType: EventType;
|
||||
playoffRound?: string; // "Quarterfinals", "Semifinals", "Finals"
|
||||
isQualifyingEvent?: boolean;
|
||||
// Template system fields (Phase 2.6)
|
||||
bracketTemplateId?: string;
|
||||
scoringStartsAtRound?: string;
|
||||
}
|
||||
|
||||
export interface UpdateScoringEventData {
|
||||
name?: string;
|
||||
eventDate?: Date;
|
||||
playoffRound?: string;
|
||||
isComplete?: boolean;
|
||||
completedAt?: Date;
|
||||
bracketTemplateId?: string;
|
||||
scoringStartsAtRound?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -37,8 +40,9 @@ export async function createScoringEvent(
|
|||
name: data.name,
|
||||
eventDate: data.eventDate ? data.eventDate.toISOString().split('T')[0] : undefined,
|
||||
eventType: data.eventType,
|
||||
playoffRound: data.playoffRound,
|
||||
isQualifyingEvent: data.isQualifyingEvent || false,
|
||||
bracketTemplateId: data.bracketTemplateId,
|
||||
scoringStartsAtRound: data.scoringStartsAtRound,
|
||||
isComplete: false,
|
||||
})
|
||||
.returning();
|
||||
|
|
@ -143,6 +147,8 @@ export async function updateScoringEvent(
|
|||
}
|
||||
if (data.isComplete !== undefined) updateData.isComplete = data.isComplete;
|
||||
if (data.completedAt !== undefined) updateData.completedAt = data.completedAt;
|
||||
if (data.bracketTemplateId !== undefined) updateData.bracketTemplateId = data.bracketTemplateId;
|
||||
if (data.scoringStartsAtRound !== undefined) updateData.scoringStartsAtRound = data.scoringStartsAtRound;
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.scoringEvents)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ import {
|
|||
findPlayoffMatchById,
|
||||
} from "~/models/playoff-match";
|
||||
import { processPlayoffEvent } from "~/models/scoring-calculator";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const sportsSeason = await findSportsSeasonById(params.id);
|
||||
|
|
@ -221,9 +224,14 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
|
||||
// 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 });
|
||||
// TODO Phase 2.7: Refactor processPlayoffEvent to use template system
|
||||
// For now, we still need to set playoffRound for the old scoring logic
|
||||
// even though we removed it from the create event UI
|
||||
const db = database();
|
||||
await db
|
||||
.update(schema.scoringEvents)
|
||||
.set({ playoffRound: round, updatedAt: new Date() })
|
||||
.where(eq(schema.scoringEvents.id, params.eventId));
|
||||
|
||||
// Get a fantasy season ID for scoring calculation
|
||||
const { findSeasonSportsBySportsSeasonId } = await import(
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
const name = formData.get("name");
|
||||
const eventType = formData.get("eventType");
|
||||
const eventDate = formData.get("eventDate");
|
||||
const playoffRound = formData.get("playoffRound");
|
||||
|
||||
// Validation
|
||||
if (typeof name !== "string" || !name.trim()) {
|
||||
|
|
@ -68,7 +67,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
name: name.trim(),
|
||||
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,
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -91,30 +91,18 @@ export default function SportsSeasonEvents({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="eventType">Event Type</Label>
|
||||
<Select name="eventType" defaultValue="playoff_game" required>
|
||||
<SelectTrigger id="eventType">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="playoff_game">Bracket</SelectItem>
|
||||
<SelectItem value="major_tournament">Major Tournament</SelectItem>
|
||||
<SelectItem value="final_standings">Final Standings</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="playoffRound">Playoff Round (Optional)</Label>
|
||||
<Input
|
||||
id="playoffRound"
|
||||
name="playoffRound"
|
||||
type="text"
|
||||
placeholder="e.g., Quarterfinals, Round 1"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="eventType">Event Type</Label>
|
||||
<Select name="eventType" defaultValue="playoff_game" required>
|
||||
<SelectTrigger id="eventType">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="playoff_game">Bracket</SelectItem>
|
||||
<SelectItem value="major_tournament">Major Tournament</SelectItem>
|
||||
<SelectItem value="final_standings">Final Standings</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
|
@ -151,7 +139,7 @@ export default function SportsSeasonEvents({
|
|||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{events.map((event: { id: string; name: string; eventType: string; playoffRound?: string | null; eventDate?: string | null; isComplete: boolean }) => (
|
||||
{events.map((event: { id: string; name: string; eventType: string; eventDate?: string | null; isComplete: boolean }) => (
|
||||
<Card key={event.id} className="hover:border-primary/50 transition-colors">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
|
|
@ -167,7 +155,6 @@ export default function SportsSeasonEvents({
|
|||
<span>
|
||||
{getEventTypeLabel(event.eventType)}
|
||||
</span>
|
||||
{event.playoffRound && <span>{event.playoffRound}</span>}
|
||||
{event.eventDate && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
|
|
|
|||
|
|
@ -308,6 +308,9 @@ export const scoringEvents = pgTable("scoring_events", {
|
|||
playoffRound: varchar("playoff_round", { length: 50 }), // "Quarterfinals", "Semifinals", "Finals"
|
||||
// For qualifying events
|
||||
isQualifyingEvent: boolean("is_qualifying_event").notNull().default(false),
|
||||
// Template system (Phase 2.6)
|
||||
bracketTemplateId: varchar("bracket_template_id", { length: 50 }), // "ncaa_68", "nfl_14", "simple_16", etc.
|
||||
scoringStartsAtRound: varchar("scoring_starts_at_round", { length: 50 }), // "Elite Eight", "Quarterfinals", etc.
|
||||
isComplete: boolean("is_complete").notNull().default(false),
|
||||
completedAt: timestamp("completed_at"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
|
|
@ -351,6 +354,10 @@ export const playoffMatches = pgTable("playoff_matches", {
|
|||
// Optional detailed data
|
||||
participant1Score: decimal("participant1_score", { precision: 10, scale: 2 }),
|
||||
participant2Score: decimal("participant2_score", { precision: 10, scale: 2 }),
|
||||
// Template system (Phase 2.6)
|
||||
isScoring: boolean("is_scoring").notNull().default(true), // Does this match affect fantasy points?
|
||||
templateRound: varchar("template_round", { length: 50 }), // "First Four", "Round of 64", "Elite Eight", etc.
|
||||
seedInfo: varchar("seed_info", { length: 50 }), // "1 vs 16", "11a vs 11b", etc. (for display)
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
|
|
|||
5
drizzle/0023_cynical_jack_power.sql
Normal file
5
drizzle/0023_cynical_jack_power.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
ALTER TABLE "playoff_matches" ADD COLUMN "is_scoring" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "playoff_matches" ADD COLUMN "template_round" varchar(50);--> statement-breakpoint
|
||||
ALTER TABLE "playoff_matches" ADD COLUMN "seed_info" varchar(50);--> statement-breakpoint
|
||||
ALTER TABLE "scoring_events" ADD COLUMN "bracket_template_id" varchar(50);--> statement-breakpoint
|
||||
ALTER TABLE "scoring_events" ADD COLUMN "scoring_starts_at_round" varchar(50);
|
||||
2730
drizzle/meta/0023_snapshot.json
Normal file
2730
drizzle/meta/0023_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -162,6 +162,13 @@
|
|||
"when": 1762191156317,
|
||||
"tag": "0022_shiny_mother_askani",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1762200550705,
|
||||
"tag": "0023_cynical_jack_power",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -970,8 +970,8 @@ All clarification questions (Q1-Q21) have been answered and confirmed:
|
|||
- [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) ✅ *Completed*
|
||||
**Goal**: Implement playoff bracket tracking and scoring for NFL/NBA/MLB
|
||||
### Phase 2: Playoff Scoring (Single Elimination)
|
||||
**Goal**: Implement playoff bracket tracking and scoring for all tournament types
|
||||
|
||||
- [x] **2.1** Playoff event creation ✅
|
||||
- [x] UI for creating playoff rounds (QF, SF, Finals)
|
||||
|
|
@ -1012,6 +1012,132 @@ All clarification questions (Q1-Q21) have been answered and confirmed:
|
|||
- [x] Test tie scenarios (2-way, 4-way, edge cases)
|
||||
- [x] All tests passing ✅
|
||||
|
||||
**LIMITATION: Phase 2.1-2.5 only supports 4 and 8 team brackets. Phases 2.6-2.9 expand to support all tournament sizes.**
|
||||
|
||||
---
|
||||
|
||||
## Bracket Expansion (Phases 2.6-2.9)
|
||||
|
||||
Current implementation only supports 4 and 8 team brackets. We need to support:
|
||||
- **Standard brackets**: 4, 8, 16, 32 teams
|
||||
- **March Madness**: 68 teams with First Four play-in games
|
||||
- **NFL Playoffs**: 14 teams with bye weeks
|
||||
- **NBA Playoffs**: 16 teams with conference structure
|
||||
- **Scoring rounds vs non-scoring rounds**: March Madness only scores Elite Eight and beyond
|
||||
|
||||
### Template-Based Bracket System
|
||||
|
||||
Create pre-defined bracket templates for common structures, with flexible participant assignment.
|
||||
|
||||
#### Bracket Template Structure
|
||||
|
||||
```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?
|
||||
}
|
||||
```
|
||||
|
||||
#### Key Templates
|
||||
|
||||
**NCAA March Madness (68 teams)**
|
||||
- First Four (4 games, 8 teams) - play-in games, NOT scoring
|
||||
- Round of 64 (32 games) - NOT scoring
|
||||
- Round of 32 (16 games) - NOT scoring
|
||||
- Sweet Sixteen (8 games) - NOT scoring
|
||||
- Elite Eight (4 games) - **SCORING STARTS** (losers share 5th-8th)
|
||||
- Final Four (2 games) - scoring (losers share 3rd-4th)
|
||||
- Championship (1 game) - scoring (1st/2nd)
|
||||
|
||||
**NFL Playoffs (14 teams)**
|
||||
- Wild Card (6 games, 12 teams) - 2 teams get byes
|
||||
- Divisional (4 games) - **SCORING STARTS** (QF, losers share 5th-8th)
|
||||
- Conference Championship (2 games) - scoring (SF, losers share 3rd-4th)
|
||||
- Super Bowl (1 game) - scoring (Finals, 1st/2nd)
|
||||
|
||||
**NBA Playoffs (16 teams)**
|
||||
- First Round (8 games) - NOT scoring
|
||||
- Conference Semifinals (4 games) - **SCORING STARTS** (QF, losers share 5th-8th)
|
||||
- Conference Finals (2 games) - scoring (SF, losers share 3rd-4th)
|
||||
- NBA Finals (1 game) - scoring (Finals, 1st/2nd)
|
||||
|
||||
**Simple 16-team and 32-team brackets**
|
||||
- Start scoring at Quarterfinals (top 8)
|
||||
- Earlier rounds award 0 points per Q20
|
||||
|
||||
#### 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", "Elite Eight", 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", "simple_16", etc.
|
||||
scoringStartsAtRound: varchar(50) // Which round starts fantasy scoring
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation Phases
|
||||
|
||||
- [x] **2.6** Template System Foundation ✅
|
||||
- [x] Define bracket template TypeScript types and constants
|
||||
- [x] Add `isScoring`, `templateRound`, `seedInfo` to playoff_matches schema
|
||||
- [x] Add `bracketTemplateId`, `scoringStartsAtRound` to scoring_events schema
|
||||
- [x] Create database migration (drizzle/0023)
|
||||
- [x] Update playoff-match model to support templates
|
||||
- [x] Create template definitions file with all bracket templates (app/lib/bracket-templates.ts)
|
||||
- [x] Add `generateBracketFromTemplate()` function
|
||||
- [x] Add `advanceWinnerTemplate()` function for template-based advancement
|
||||
- [x] Test suite for template structures (16 tests passing)
|
||||
|
||||
- [ ] **2.7** Simple Template Expansion (16, 32 teams) ⏭️ **NEXT**
|
||||
- [x] Create template definitions for 16 and 32 team brackets (already done in 2.6)
|
||||
- [ ] Update bracket generation UI to use template selector
|
||||
- [ ] Test with 16-team bracket (Round of 16 → QF → SF → Finals)
|
||||
- [ ] Test with 32-team bracket (Round of 32 → Round of 16 → QF → SF → Finals)
|
||||
- [ ] Update scoring calculator for non-scoring rounds (0 points per Q20)
|
||||
- [ ] Verify Elite Eight scoring starts correctly
|
||||
|
||||
- [ ] **2.8** March Madness Template (68 teams)
|
||||
- [ ] Create NCAA_68 template definition
|
||||
- [ ] Build First Four matchup assignment UI (4 play-in games)
|
||||
- [ ] Build Round of 64 with TBD slot handling (winners from First Four)
|
||||
- [ ] Implement winner advancement from First Four to Round of 64
|
||||
- [ ] Test complete 68-team bracket flow
|
||||
- [ ] Verify only Elite Eight and beyond score points
|
||||
- [ ] Test early elimination = 0 points (per Q20)
|
||||
|
||||
- [ ] **2.9** NFL/NBA Templates
|
||||
- [ ] Create NFL_14 template (with bye weeks)
|
||||
- [ ] Create NBA_16 template (with conference structure)
|
||||
- [ ] Handle bye week logic (teams skip Wild Card round)
|
||||
- [ ] Test NFL playoff bracket
|
||||
- [ ] Test NBA playoff bracket
|
||||
- [ ] Verify scoring starts at correct round for each template
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Other Scoring Patterns
|
||||
**Goal**: Implement season standings (F1) and qualifying points (Golf/Tennis)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue