Refactor code structure for improved readability and maintainability

This commit is contained in:
Chris Parsons 2025-11-03 13:16:37 -08:00
parent a3b8fa6309
commit da1a17f2d3
12 changed files with 3588 additions and 36 deletions

View 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;
}

View 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);
});
});
});

View file

@ -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 });
}

View file

@ -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)

View file

@ -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(

View file

@ -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 {

View file

@ -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" />

View file

@ -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(),
});

View 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);

File diff suppressed because it is too large Load diff

View file

@ -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
}
]
}

View file

@ -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)