Replaces the hardcoded 2026 Men's region structure with a per-event configurable system so the same ncaa_68 template works for Women's March Madness (different region names and play-in placements each year). - Add `bracket_region_config jsonb` column to `scoring_events` - Export `ALL_16_SEEDS` and `BracketRegion`/`BracketPlayIn` interfaces from bracket-templates.ts so both server and client share the type - Admin bracket entry form now shows a "Configure Regions" section: editable region names + add/remove play-in seed selectors (ShadCN Select) per region; slot map updates live as config changes - Server action parses region config from form, validates total = 68, stores it on the event, and passes it to bracket generation - `advanceFirstFourWinner` reads stored `bracketRegionConfig` from the event row first, falls back to template.regions for existing events - Fix opponentSeed formula (was `seedNum <= 8 ? 17-n : n`, always `17-n`) - Remove duplicate ALL_SEEDS inline definitions (now a shared constant) Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
67c7de1924
commit
0277c4d25e
12 changed files with 4298 additions and 221 deletions
|
|
@ -25,6 +25,37 @@ export interface GroupStageConfig {
|
|||
groupLabels: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A play-in game within a bracket region (e.g., NCAA First Four).
|
||||
* Two teams compete; the winner fills a specific seed slot in the main bracket.
|
||||
*/
|
||||
export interface BracketPlayIn {
|
||||
/** Which seed slot the winner fills (e.g., 11 or 16) */
|
||||
seedSlot: number;
|
||||
/** Number of teams in this play-in game (always 2) */
|
||||
teams: 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* A named region within a bracket (e.g., NCAA "East", "South").
|
||||
* Each region contributes exactly 16 teams to the Round of 64.
|
||||
*/
|
||||
export interface BracketRegion {
|
||||
/** Display name (e.g., "East", "South") */
|
||||
name: string;
|
||||
/**
|
||||
* Seed numbers that enter the region directly (no play-in).
|
||||
* Must be ascending and contain every seed 1–16 except those covered by playIns.
|
||||
* Length = 16 - playIns.length.
|
||||
*/
|
||||
directSeeds: number[];
|
||||
/**
|
||||
* Play-in games for this region. Order here determines the order their teams
|
||||
* appear in the participant array (after all direct-seed teams across all regions).
|
||||
*/
|
||||
playIns: BracketPlayIn[];
|
||||
}
|
||||
|
||||
export interface BracketTemplate {
|
||||
/** Unique identifier for this template */
|
||||
id: string;
|
||||
|
|
@ -40,6 +71,90 @@ export interface BracketTemplate {
|
|||
groupStage?: GroupStageConfig;
|
||||
/** Number of teams advancing to knockout stage (when groupStage is defined) */
|
||||
knockoutTeams?: number;
|
||||
/**
|
||||
* Optional named region config. When present, the participant array for bracket
|
||||
* generation is structured as: all direct seeds region-by-region in order, then
|
||||
* all play-in teams grouped by region (and play-in order within each region).
|
||||
*/
|
||||
regions?: BracketRegion[];
|
||||
}
|
||||
|
||||
/** All seed numbers in a standard 16-team regional bracket (1–16) */
|
||||
export const ALL_16_SEEDS: number[] = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16];
|
||||
|
||||
// Standard seeding order for a 16-team regional bracket
|
||||
// Each entry is [higherSeed, lowerSeed] (lower number = better seed)
|
||||
export const STANDARD_BRACKET_SEEDING: [number, number][] = [
|
||||
[1, 16], [8, 9], [5, 12], [4, 13], [6, 11], [3, 14], [7, 10], [2, 15],
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns the 0-indexed position within the standard 8-match bracket order
|
||||
* for a given seed slot. Used to compute which Round of 64 match a play-in
|
||||
* winner advances to.
|
||||
*
|
||||
* Standard order: 1v16, 8v9, 5v12, 4v13, 6v11, 3v14, 7v10, 2v15
|
||||
*/
|
||||
export function matchIndexForSeedSlot(seedSlot: number): number {
|
||||
for (let i = 0; i < STANDARD_BRACKET_SEEDING.length; i++) {
|
||||
if (STANDARD_BRACKET_SEEDING[i].includes(seedSlot)) return i;
|
||||
}
|
||||
throw new Error(`Seed slot ${seedSlot} not found in standard bracket seeding`);
|
||||
}
|
||||
|
||||
export interface NCAA68SlotMap {
|
||||
/** Start index in participantIds for each region's direct seeds */
|
||||
directOffsets: number[];
|
||||
/** Ordered list of all play-ins, with participant index info and region context */
|
||||
playInOffsets: Array<{
|
||||
regionIndex: number;
|
||||
playInIndex: number;
|
||||
/** Index of the first team in this play-in within participantIds */
|
||||
startIndex: number;
|
||||
seedSlot: number;
|
||||
}>;
|
||||
totalDirect: number;
|
||||
totalPlayIn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the participant array slot mapping for an NCAA-style bracket with regions.
|
||||
*
|
||||
* Participant array layout:
|
||||
* [region 0 direct seeds] [region 1 direct seeds] ... [region N direct seeds]
|
||||
* [region 0 play-in 0 team A, team B] [region 0 play-in 1 team A, team B] ...
|
||||
* [region 1 play-in 0 team A, team B] ...
|
||||
*
|
||||
* This layout is shared between the server (bracket generation) and the client
|
||||
* (form rendering) so both sides agree on which participant{i} maps where.
|
||||
*/
|
||||
export function buildNCAA68SlotMap(regions: BracketRegion[]): NCAA68SlotMap {
|
||||
let cursor = 0;
|
||||
const directOffsets: number[] = [];
|
||||
|
||||
for (const region of regions) {
|
||||
directOffsets.push(cursor);
|
||||
cursor += region.directSeeds.length;
|
||||
}
|
||||
|
||||
const totalDirect = cursor;
|
||||
const playInOffsets: NCAA68SlotMap["playInOffsets"] = [];
|
||||
|
||||
for (let r = 0; r < regions.length; r++) {
|
||||
for (let p = 0; p < regions[r].playIns.length; p++) {
|
||||
playInOffsets.push({
|
||||
regionIndex: r,
|
||||
playInIndex: p,
|
||||
startIndex: cursor,
|
||||
seedSlot: regions[r].playIns[p].seedSlot,
|
||||
});
|
||||
cursor += 2;
|
||||
}
|
||||
}
|
||||
|
||||
const totalPlayIn = cursor - totalDirect;
|
||||
|
||||
return { directOffsets, playInOffsets, totalDirect, totalPlayIn };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -186,6 +301,22 @@ export const SIMPLE_32: BracketTemplate = {
|
|||
* 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
|
||||
*
|
||||
* 2026 region config:
|
||||
* East — 16 direct seeds, no play-ins
|
||||
* South — 15 direct seeds, 16-seed play-in
|
||||
* West — 15 direct seeds, 11-seed play-in
|
||||
* Midwest — 14 direct seeds, 11-seed play-in + 16-seed play-in
|
||||
*
|
||||
* Participant array layout (68 slots):
|
||||
* [0–15] East seeds 1–16
|
||||
* [16–30] South seeds 1–15
|
||||
* [31–45] West seeds 1–10, 12–16
|
||||
* [46–59] Midwest seeds 1–10, 12–15
|
||||
* [60–61] South 16-seed play-in (2 teams)
|
||||
* [62–63] West 11-seed play-in (2 teams)
|
||||
* [64–65] Midwest 11-seed play-in (2 teams)
|
||||
* [66–67] Midwest 16-seed play-in (2 teams)
|
||||
*/
|
||||
export const NCAA_68: BracketTemplate = {
|
||||
id: "ncaa_68",
|
||||
|
|
@ -236,6 +367,35 @@ export const NCAA_68: BracketTemplate = {
|
|||
isScoring: true, // Winner 1st, Loser 2nd
|
||||
},
|
||||
],
|
||||
regions: [
|
||||
{
|
||||
// East: all 16 seeds enter directly — no play-in games
|
||||
name: "East",
|
||||
directSeeds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
|
||||
playIns: [],
|
||||
},
|
||||
{
|
||||
// South: seeds 1–15 direct; 16-seed determined by First Four play-in
|
||||
name: "South",
|
||||
directSeeds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||
playIns: [{ seedSlot: 16, teams: 2 }],
|
||||
},
|
||||
{
|
||||
// West: seeds 1–10 and 12–16 direct; 11-seed determined by First Four play-in
|
||||
name: "West",
|
||||
directSeeds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 13, 14, 15, 16],
|
||||
playIns: [{ seedSlot: 11, teams: 2 }],
|
||||
},
|
||||
{
|
||||
// Midwest: seeds 1–10 and 12–15 direct; both 11-seed and 16-seed via play-ins
|
||||
name: "Midwest",
|
||||
directSeeds: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 13, 14, 15],
|
||||
playIns: [
|
||||
{ seedSlot: 11, teams: 2 },
|
||||
{ seedSlot: 16, teams: 2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { NCAA_68, getScoringRoundType } from "~/lib/bracket-templates";
|
||||
import {
|
||||
NCAA_68,
|
||||
getScoringRoundType,
|
||||
buildNCAA68SlotMap,
|
||||
matchIndexForSeedSlot,
|
||||
STANDARD_BRACKET_SEEDING,
|
||||
} from "~/lib/bracket-templates";
|
||||
|
||||
/**
|
||||
* NCAA 68 Bracket Unit Tests - Phase 2.8
|
||||
|
|
@ -202,4 +208,180 @@ describe("NCAA 68 Bracket Template - Phase 2.8", () => {
|
|||
expect(directSeeds + firstFourTeams).toBe(68);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Region Config (2026)", () => {
|
||||
it("has 4 named regions", () => {
|
||||
expect(NCAA_68.regions).toBeDefined();
|
||||
expect(NCAA_68.regions).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("has regions in order: East, South, West, Midwest", () => {
|
||||
const names = NCAA_68.regions!.map((r) => r.name);
|
||||
expect(names).toEqual(["East", "South", "West", "Midwest"]);
|
||||
});
|
||||
|
||||
it("East has 16 direct seeds and no play-ins", () => {
|
||||
const east = NCAA_68.regions![0];
|
||||
expect(east.directSeeds).toHaveLength(16);
|
||||
expect(east.playIns).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("South has 15 direct seeds and one 16-seed play-in", () => {
|
||||
const south = NCAA_68.regions![1];
|
||||
expect(south.directSeeds).toHaveLength(15);
|
||||
expect(south.playIns).toHaveLength(1);
|
||||
expect(south.playIns[0].seedSlot).toBe(16);
|
||||
});
|
||||
|
||||
it("West has 15 direct seeds and one 11-seed play-in", () => {
|
||||
const west = NCAA_68.regions![2];
|
||||
expect(west.directSeeds).toHaveLength(15);
|
||||
expect(west.playIns).toHaveLength(1);
|
||||
expect(west.playIns[0].seedSlot).toBe(11);
|
||||
});
|
||||
|
||||
it("Midwest has 14 direct seeds and two play-ins (11 and 16)", () => {
|
||||
const midwest = NCAA_68.regions![3];
|
||||
expect(midwest.directSeeds).toHaveLength(14);
|
||||
expect(midwest.playIns).toHaveLength(2);
|
||||
expect(midwest.playIns[0].seedSlot).toBe(11);
|
||||
expect(midwest.playIns[1].seedSlot).toBe(16);
|
||||
});
|
||||
|
||||
it("total direct seeds across all regions equals 60", () => {
|
||||
const total = NCAA_68.regions!.reduce((sum, r) => sum + r.directSeeds.length, 0);
|
||||
expect(total).toBe(60);
|
||||
});
|
||||
|
||||
it("total play-in teams across all regions equals 8", () => {
|
||||
const total = NCAA_68.regions!.reduce(
|
||||
(sum, r) => sum + r.playIns.reduce((s, pi) => s + pi.teams, 0),
|
||||
0
|
||||
);
|
||||
expect(total).toBe(8);
|
||||
});
|
||||
|
||||
it("direct + play-in teams sum to 68", () => {
|
||||
const direct = NCAA_68.regions!.reduce((sum, r) => sum + r.directSeeds.length, 0);
|
||||
const playIn = NCAA_68.regions!.reduce(
|
||||
(sum, r) => sum + r.playIns.reduce((s, pi) => s + pi.teams, 0),
|
||||
0
|
||||
);
|
||||
expect(direct + playIn).toBe(68);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildNCAA68SlotMap", () => {
|
||||
const slotMap = buildNCAA68SlotMap(NCAA_68.regions!);
|
||||
|
||||
it("East starts at index 0", () => {
|
||||
expect(slotMap.directOffsets[0]).toBe(0);
|
||||
});
|
||||
|
||||
it("South starts at index 16 (after East's 16 direct seeds)", () => {
|
||||
expect(slotMap.directOffsets[1]).toBe(16);
|
||||
});
|
||||
|
||||
it("West starts at index 31 (after East 16 + South 15)", () => {
|
||||
expect(slotMap.directOffsets[2]).toBe(31);
|
||||
});
|
||||
|
||||
it("Midwest starts at index 46 (after East 16 + South 15 + West 15)", () => {
|
||||
expect(slotMap.directOffsets[3]).toBe(46);
|
||||
});
|
||||
|
||||
it("totalDirect is 60", () => {
|
||||
expect(slotMap.totalDirect).toBe(60);
|
||||
});
|
||||
|
||||
it("totalPlayIn is 8", () => {
|
||||
expect(slotMap.totalPlayIn).toBe(8);
|
||||
});
|
||||
|
||||
it("has 4 play-in offsets", () => {
|
||||
expect(slotMap.playInOffsets).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("FF1 (South 16-seed) starts at index 60", () => {
|
||||
const ff1 = slotMap.playInOffsets[0];
|
||||
expect(ff1.regionIndex).toBe(1); // South
|
||||
expect(ff1.seedSlot).toBe(16);
|
||||
expect(ff1.startIndex).toBe(60);
|
||||
});
|
||||
|
||||
it("FF2 (West 11-seed) starts at index 62", () => {
|
||||
const ff2 = slotMap.playInOffsets[1];
|
||||
expect(ff2.regionIndex).toBe(2); // West
|
||||
expect(ff2.seedSlot).toBe(11);
|
||||
expect(ff2.startIndex).toBe(62);
|
||||
});
|
||||
|
||||
it("FF3 (Midwest 11-seed) starts at index 64", () => {
|
||||
const ff3 = slotMap.playInOffsets[2];
|
||||
expect(ff3.regionIndex).toBe(3); // Midwest
|
||||
expect(ff3.seedSlot).toBe(11);
|
||||
expect(ff3.startIndex).toBe(64);
|
||||
});
|
||||
|
||||
it("FF4 (Midwest 16-seed) starts at index 66", () => {
|
||||
const ff4 = slotMap.playInOffsets[3];
|
||||
expect(ff4.regionIndex).toBe(3); // Midwest
|
||||
expect(ff4.seedSlot).toBe(16);
|
||||
expect(ff4.startIndex).toBe(66);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchIndexForSeedSlot", () => {
|
||||
it("seed 16 is at match index 0 (1v16)", () => {
|
||||
expect(matchIndexForSeedSlot(16)).toBe(0);
|
||||
});
|
||||
|
||||
it("seed 1 is at match index 0 (1v16)", () => {
|
||||
expect(matchIndexForSeedSlot(1)).toBe(0);
|
||||
});
|
||||
|
||||
it("seed 11 is at match index 4 (6v11)", () => {
|
||||
expect(matchIndexForSeedSlot(11)).toBe(4);
|
||||
});
|
||||
|
||||
it("seed 6 is at match index 4 (6v11)", () => {
|
||||
expect(matchIndexForSeedSlot(6)).toBe(4);
|
||||
});
|
||||
|
||||
it("all 8 standard seeding pairs are covered", () => {
|
||||
for (const [hi, lo] of STANDARD_BRACKET_SEEDING) {
|
||||
expect(() => matchIndexForSeedSlot(hi)).not.toThrow();
|
||||
expect(() => matchIndexForSeedSlot(lo)).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("First Four → Round of 64 match number derivation", () => {
|
||||
const slotMap = buildNCAA68SlotMap(NCAA_68.regions!);
|
||||
|
||||
// r64MatchNumber = regionIndex * 8 + matchIndexForSeedSlot(seedSlot) + 1
|
||||
it("FF1 (South region index 1, seed 16) → R64 match 9", () => {
|
||||
const ff1 = slotMap.playInOffsets[0]; // South 16-seed
|
||||
const r64 = ff1.regionIndex * 8 + matchIndexForSeedSlot(ff1.seedSlot) + 1;
|
||||
expect(r64).toBe(9); // East occupies matches 1-8; South match index 0 → 9
|
||||
});
|
||||
|
||||
it("FF2 (West region index 2, seed 11) → R64 match 21", () => {
|
||||
const ff2 = slotMap.playInOffsets[1]; // West 11-seed
|
||||
const r64 = ff2.regionIndex * 8 + matchIndexForSeedSlot(ff2.seedSlot) + 1;
|
||||
expect(r64).toBe(21); // West starts at 17; match index 4 → 17+4=21
|
||||
});
|
||||
|
||||
it("FF3 (Midwest region index 3, seed 11) → R64 match 29", () => {
|
||||
const ff3 = slotMap.playInOffsets[2]; // Midwest 11-seed
|
||||
const r64 = ff3.regionIndex * 8 + matchIndexForSeedSlot(ff3.seedSlot) + 1;
|
||||
expect(r64).toBe(29); // Midwest starts at 25; match index 4 → 25+4=29
|
||||
});
|
||||
|
||||
it("FF4 (Midwest region index 3, seed 16) → R64 match 25", () => {
|
||||
const ff4 = slotMap.playInOffsets[3]; // Midwest 16-seed
|
||||
const r64 = ff4.regionIndex * 8 + matchIndexForSeedSlot(ff4.seedSlot) + 1;
|
||||
expect(r64).toBe(25); // Midwest starts at 25; match index 0 → 25
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
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";
|
||||
import type { BracketTemplate, BracketRegion } from "~/lib/bracket-templates";
|
||||
import {
|
||||
getBracketTemplate,
|
||||
buildNCAA68SlotMap,
|
||||
matchIndexForSeedSlot,
|
||||
STANDARD_BRACKET_SEEDING,
|
||||
} from "~/lib/bracket-templates";
|
||||
|
||||
export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
|
||||
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
|
||||
|
|
@ -256,87 +261,6 @@ export async function advanceWinner(
|
|||
await updatePlayoffMatch(nextMatch.id, { [participantSlot]: winnerId });
|
||||
}
|
||||
|
||||
/**
|
||||
* NCAA 68 First Four seeding
|
||||
* Returns matchups for the 4 play-in games
|
||||
*
|
||||
* First Four structure (using 0-indexed participant array):
|
||||
* - Games 1-2: Participants 64-67 (16 seeds, lowest auto-qualifiers)
|
||||
* - Winners are 16 seeds and face 1 seeds in Round of 64
|
||||
* - Games 3-4: Participants 60-63 (11/12 seeds, last at-large bids)
|
||||
* - Winners are 11 seeds and face 6 seeds in Round of 64
|
||||
*/
|
||||
function generateNCAA68FirstFourSeeding(): [number, number][] {
|
||||
return [
|
||||
[64, 65], // First Four Game 1: 16-seed play-in A (winner is 16 seed, faces 1 seed)
|
||||
[66, 67], // First Four Game 2: 16-seed play-in B (winner is 16 seed, faces 1 seed)
|
||||
[60, 61], // First Four Game 3: 11/12-seed play-in A (winner is 11 seed, faces 6 seed)
|
||||
[62, 63], // First Four Game 4: 11/12-seed play-in B (winner is 11 seed, faces 6 seed)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* NCAA 68 Round of 64 seeding
|
||||
* Returns matchups for the Round of 64, with 4 TBD slots from First Four
|
||||
*
|
||||
* 68 teams total: 60 directly seeded (0-59) + 8 in First Four (60-67)
|
||||
* Round of 64 needs 64 participants: 60 direct + 4 First Four winners
|
||||
*
|
||||
* Distribution: Each region has 15 direct seeds + 1 FF winner = 16 per region
|
||||
* - Region 1: Seeds 0-14 + FF1 (FF1 winner is 16 seed, faces 1 seed)
|
||||
* - Region 2: Seeds 15-29 + FF2 (FF2 winner is 16 seed, faces 1 seed)
|
||||
* - Region 3: Seeds 30-44 + FF3 (FF3 winner is 11 seed, faces 6 seed)
|
||||
* - Region 4: Seeds 45-59 + FF4 (FF4 winner is 11 seed, faces 6 seed)
|
||||
*
|
||||
* @returns Array of [seed1, seed2 or "FF{gameNum}"] pairs for 32 matches
|
||||
*/
|
||||
function generateNCAA68RoundOf64Seeding(): ([number, number] | [number, string] | [string, number])[] {
|
||||
const matchups: ([number, number] | [number, string] | [string, number])[] = [];
|
||||
|
||||
// Region 1: Seeds 0-14 + FF1 (16 total)
|
||||
// Standard bracket order: 1v16, 8v9, 5v12, 4v13, 6v11, 3v14, 7v10, 2v15
|
||||
matchups.push([0, "FF1"]); // #1 vs #16 (FF1 winner)
|
||||
matchups.push([7, 8]); // #8 vs #9
|
||||
matchups.push([4, 11]); // #5 vs #12
|
||||
matchups.push([3, 12]); // #4 vs #13
|
||||
matchups.push([5, 10]); // #6 vs #11
|
||||
matchups.push([2, 13]); // #3 vs #14
|
||||
matchups.push([6, 9]); // #7 vs #10
|
||||
matchups.push([1, 14]); // #2 vs #15
|
||||
|
||||
// Region 2: Seeds 15-29 + FF2 (16 total)
|
||||
matchups.push([15, "FF2"]); // #1 vs #16 (FF2 winner)
|
||||
matchups.push([22, 23]); // #8 vs #9
|
||||
matchups.push([19, 26]); // #5 vs #12
|
||||
matchups.push([18, 27]); // #4 vs #13
|
||||
matchups.push([20, 25]); // #6 vs #11
|
||||
matchups.push([17, 28]); // #3 vs #14
|
||||
matchups.push([21, 24]); // #7 vs #10
|
||||
matchups.push([16, 29]); // #2 vs #15
|
||||
|
||||
// Region 3: Seeds 30-44 + FF3 (16 total)
|
||||
matchups.push([30, 44]); // #1 vs #16
|
||||
matchups.push([37, 38]); // #8 vs #9
|
||||
matchups.push([34, 41]); // #5 vs #12
|
||||
matchups.push([33, 42]); // #4 vs #13
|
||||
matchups.push([35, "FF3"]); // #6 vs #11 (FF3 winner = 11 seed)
|
||||
matchups.push([32, 43]); // #3 vs #14
|
||||
matchups.push([36, 39]); // #7 vs #10
|
||||
matchups.push([31, 40]); // #2 vs #15
|
||||
|
||||
// Region 4: Seeds 45-59 + FF4 (16 total)
|
||||
matchups.push([45, 59]); // #1 vs #16
|
||||
matchups.push([52, 53]); // #8 vs #9
|
||||
matchups.push([49, 56]); // #5 vs #12
|
||||
matchups.push([48, 57]); // #4 vs #13
|
||||
matchups.push([50, "FF4"]); // #6 vs #11 (FF4 winner = 11 seed)
|
||||
matchups.push([47, 58]); // #3 vs #14
|
||||
matchups.push([51, 54]); // #7 vs #10
|
||||
matchups.push([46, 55]); // #2 vs #15
|
||||
|
||||
return matchups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate standard tournament seeding matchups
|
||||
* Returns pairs of seed indices for proper bracket balance
|
||||
|
|
@ -395,7 +319,8 @@ function generateStandardSeeding(teamCount: number): [number, number][] {
|
|||
export async function generateBracketFromTemplate(
|
||||
eventId: string,
|
||||
templateId: string,
|
||||
participantIds?: string[]
|
||||
participantIds?: string[],
|
||||
regionOverride?: BracketRegion[]
|
||||
): Promise<PlayoffMatch[]> {
|
||||
const template = getBracketTemplate(templateId);
|
||||
if (!template) {
|
||||
|
|
@ -415,8 +340,12 @@ export async function generateBracketFromTemplate(
|
|||
}
|
||||
|
||||
// NCAA 68 requires special handling for First Four and Round of 64
|
||||
// Use regionOverride if supplied (per-event config), otherwise fall back to template.regions
|
||||
if (templateId === "ncaa_68") {
|
||||
return await generateNCAA68Bracket(eventId, template, participantIds);
|
||||
const effectiveTemplate = regionOverride
|
||||
? { ...template, regions: regionOverride }
|
||||
: template;
|
||||
return await generateNCAA68Bracket(eventId, effectiveTemplate, participantIds);
|
||||
}
|
||||
|
||||
// NFL 14 requires special handling for bye weeks
|
||||
|
|
@ -473,70 +402,96 @@ export async function generateBracketFromTemplate(
|
|||
}
|
||||
|
||||
/**
|
||||
* Generate NCAA 68 bracket with First Four and Round of 64
|
||||
* Phase 2.8: Special handling for First Four play-in games
|
||||
* Generate NCAA 68 bracket using the template's regions config.
|
||||
*
|
||||
* Participant array layout (driven by buildNCAA68SlotMap):
|
||||
* [region 0 direct seeds] [region 1 direct seeds] ...
|
||||
* [play-in teams in region/play-in order, 2 per game]
|
||||
*/
|
||||
async function generateNCAA68Bracket(
|
||||
eventId: string,
|
||||
template: BracketTemplate,
|
||||
participantIds?: string[]
|
||||
): Promise<PlayoffMatch[]> {
|
||||
const matches: NewPlayoffMatch[] = [];
|
||||
if (!template.regions || template.regions.length === 0) {
|
||||
throw new Error("NCAA 68 template requires a regions config");
|
||||
}
|
||||
|
||||
// First Four: 4 play-in games
|
||||
const firstFourSeeding = generateNCAA68FirstFourSeeding();
|
||||
for (let i = 0; i < firstFourSeeding.length; i++) {
|
||||
const [seed1, seed2] = firstFourSeeding[i];
|
||||
const matches: NewPlayoffMatch[] = [];
|
||||
const slotMap = buildNCAA68SlotMap(template.regions);
|
||||
|
||||
// Per-region map: region index → FF labels for that region's play-ins (e.g. "FF1")
|
||||
const regionFFLabels = new Map<number, string[]>();
|
||||
for (let i = 0; i < slotMap.playInOffsets.length; i++) {
|
||||
const { regionIndex, playInIndex } = slotMap.playInOffsets[i];
|
||||
if (!regionFFLabels.has(regionIndex)) regionFFLabels.set(regionIndex, []);
|
||||
regionFFLabels.get(regionIndex)![playInIndex] = `FF${i + 1}`;
|
||||
}
|
||||
|
||||
// ── First Four ────────────────────────────────────────────────────────────
|
||||
for (let i = 0; i < slotMap.playInOffsets.length; i++) {
|
||||
const { startIndex, seedSlot, regionIndex } = slotMap.playInOffsets[i];
|
||||
const regionName = template.regions[regionIndex].name;
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
round: "First Four",
|
||||
matchNumber: i + 1,
|
||||
participant1Id: participantIds ? participantIds[seed1] : null,
|
||||
participant2Id: participantIds ? participantIds[seed2] : null,
|
||||
participant1Id: participantIds ? (participantIds[startIndex] ?? null) : null,
|
||||
participant2Id: participantIds ? (participantIds[startIndex + 1] ?? null) : null,
|
||||
isComplete: false,
|
||||
isScoring: false, // First Four doesn't score fantasy points
|
||||
isScoring: false,
|
||||
templateRound: "First Four",
|
||||
seedInfo: `${seed1 + 1} vs ${seed2 + 1}`,
|
||||
seedInfo: `${regionName} ${seedSlot}-seed play-in`,
|
||||
});
|
||||
}
|
||||
|
||||
// Round of 64: 32 games with some TBD slots from First Four
|
||||
const roundOf64Seeding = generateNCAA68RoundOf64Seeding();
|
||||
for (let i = 0; i < roundOf64Seeding.length; i++) {
|
||||
const matchup = roundOf64Seeding[i];
|
||||
let participant1Id: string | null = null;
|
||||
let participant2Id: string | null = null;
|
||||
let seedInfo: string | null = null;
|
||||
// ── Round of 64: 8 matches per region ────────────────────────────────────
|
||||
let r64MatchNumber = 1;
|
||||
for (let r = 0; r < template.regions.length; r++) {
|
||||
const region = template.regions[r];
|
||||
const directOffset = slotMap.directOffsets[r];
|
||||
const labels = regionFFLabels.get(r) ?? [];
|
||||
|
||||
if (participantIds) {
|
||||
const [seed1, seed2] = matchup;
|
||||
|
||||
// Handle TBD slots (FF1-FF4 placeholders)
|
||||
if (typeof seed1 === "number") {
|
||||
participant1Id = participantIds[seed1];
|
||||
} // else it's "FF1", "FF2", etc. - will be filled by First Four winner
|
||||
|
||||
if (typeof seed2 === "number") {
|
||||
participant2Id = participantIds[seed2];
|
||||
}
|
||||
|
||||
seedInfo = `${typeof seed1 === "number" ? seed1 + 1 : seed1} vs ${typeof seed2 === "number" ? seed2 + 1 : seed2}`;
|
||||
// Map seed rank → participant array index or FF label
|
||||
const seedToSlot = new Map<number, number | string>();
|
||||
for (let i = 0; i < region.directSeeds.length; i++) {
|
||||
seedToSlot.set(region.directSeeds[i], directOffset + i);
|
||||
}
|
||||
for (let p = 0; p < region.playIns.length; p++) {
|
||||
seedToSlot.set(region.playIns[p].seedSlot, labels[p]);
|
||||
}
|
||||
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
round: "Round of 64",
|
||||
matchNumber: i + 1,
|
||||
participant1Id,
|
||||
participant2Id,
|
||||
isComplete: false,
|
||||
isScoring: false, // Round of 64 doesn't score
|
||||
templateRound: "Round of 64",
|
||||
seedInfo,
|
||||
});
|
||||
for (const [hiSeed, loSeed] of STANDARD_BRACKET_SEEDING) {
|
||||
const slot1 = seedToSlot.get(hiSeed);
|
||||
const slot2 = seedToSlot.get(loSeed);
|
||||
|
||||
if (slot1 === undefined || slot2 === undefined) {
|
||||
throw new Error(
|
||||
`Region "${region.name}" missing seed ${slot1 === undefined ? hiSeed : loSeed}`
|
||||
);
|
||||
}
|
||||
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
round: "Round of 64",
|
||||
matchNumber: r64MatchNumber++,
|
||||
participant1Id:
|
||||
participantIds && typeof slot1 === "number"
|
||||
? (participantIds[slot1] ?? null)
|
||||
: null,
|
||||
participant2Id:
|
||||
participantIds && typeof slot2 === "number"
|
||||
? (participantIds[slot2] ?? null)
|
||||
: null,
|
||||
isComplete: false,
|
||||
isScoring: false,
|
||||
templateRound: "Round of 64",
|
||||
seedInfo: `${region.name}: ${hiSeed} vs ${typeof slot2 === "string" ? slot2 : loSeed}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate remaining rounds (Round of 32, Sweet Sixteen, Elite Eight, Final Four, Championship)
|
||||
// ── Round of 32 → Championship (empty match shells) ──────────────────────
|
||||
for (let roundIndex = 2; roundIndex < template.rounds.length; roundIndex++) {
|
||||
const round = template.rounds[roundIndex];
|
||||
for (let i = 0; i < round.matchCount; i++) {
|
||||
|
|
@ -931,9 +886,9 @@ export async function advanceWinnerTemplate(
|
|||
}
|
||||
|
||||
// Special handling for NCAA 68 First Four
|
||||
// Phase 2.8: First Four winners advance to specific Round of 64 slots
|
||||
// First Four winners advance to Round of 64 slots derived from the regions config
|
||||
if (template.id === "ncaa_68" && match.round === "First Four") {
|
||||
return await advanceFirstFourWinner(match, winnerId);
|
||||
return await advanceFirstFourWinner(match, winnerId, template);
|
||||
}
|
||||
|
||||
// Find current round in template
|
||||
|
|
@ -986,49 +941,58 @@ export async function advanceWinnerTemplate(
|
|||
}
|
||||
|
||||
/**
|
||||
* Advance First Four winner to Round of 64
|
||||
* Phase 2.8: NCAA 68 specific logic
|
||||
* Advance a First Four winner to their target Round of 64 slot.
|
||||
*
|
||||
* First Four match → Round of 64 match mapping:
|
||||
* - FF Match 1 (16-seed) → R64 Match 1 (Region 1 vs #1 seed), participant2Id
|
||||
* - FF Match 2 (16-seed) → R64 Match 9 (Region 2 vs #1 seed), participant2Id
|
||||
* - FF Match 3 (11-seed) → R64 Match 21 (Region 3 vs #6 seed), participant2Id
|
||||
* - FF Match 4 (11-seed) → R64 Match 29 (Region 4 vs #6 seed), participant2Id
|
||||
* The target match number is computed dynamically from the template's regions config:
|
||||
* r64MatchNumber = regionIndex * 8 + matchIndexForSeedSlot(seedSlot) + 1
|
||||
*
|
||||
* The winner always fills participant2Id (they are the lower seed in their matchup).
|
||||
*/
|
||||
async function advanceFirstFourWinner(
|
||||
firstFourMatch: PlayoffMatch,
|
||||
winnerId: string
|
||||
winnerId: string,
|
||||
template: BracketTemplate
|
||||
): Promise<void> {
|
||||
// Map First Four match numbers to Round of 64 match numbers
|
||||
const firstFourToRoundOf64: Record<number, number> = {
|
||||
1: 1, // FF Game 1 (16-seed) → R64 Match 1 (vs 1 seed)
|
||||
2: 9, // FF Game 2 (16-seed) → R64 Match 9 (vs 1 seed)
|
||||
3: 21, // FF Game 3 (11-seed) → R64 Match 21 (vs 6 seed)
|
||||
4: 29, // FF Game 4 (11-seed) → R64 Match 29 (vs 6 seed)
|
||||
};
|
||||
// Prefer the per-event stored region config (set at bracket generation time);
|
||||
// fall back to the template's built-in regions for backwards compatibility.
|
||||
const db = database();
|
||||
const eventRow = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.id, firstFourMatch.scoringEventId),
|
||||
columns: { bracketRegionConfig: true },
|
||||
});
|
||||
const regions =
|
||||
(eventRow?.bracketRegionConfig as BracketRegion[] | null) ?? template.regions;
|
||||
|
||||
const targetMatchNumber = firstFourToRoundOf64[firstFourMatch.matchNumber];
|
||||
if (!targetMatchNumber) {
|
||||
if (!regions) {
|
||||
throw new Error("NCAA 68 template requires regions config for First Four advancement");
|
||||
}
|
||||
|
||||
const slotMap = buildNCAA68SlotMap(regions);
|
||||
const ffIndex = firstFourMatch.matchNumber - 1; // 0-indexed
|
||||
|
||||
if (ffIndex < 0 || ffIndex >= slotMap.playInOffsets.length) {
|
||||
throw new Error(`Invalid First Four match number: ${firstFourMatch.matchNumber}`);
|
||||
}
|
||||
|
||||
// Find the target Round of 64 match
|
||||
const { regionIndex, seedSlot } = slotMap.playInOffsets[ffIndex];
|
||||
const matchIndexInRegion = matchIndexForSeedSlot(seedSlot);
|
||||
// Each region contributes exactly 8 matches to the Round of 64, in region order
|
||||
const r64MatchNumber = regionIndex * 8 + matchIndexInRegion + 1;
|
||||
|
||||
const roundOf64Matches = await findPlayoffMatchesByEventIdAndRound(
|
||||
firstFourMatch.scoringEventId,
|
||||
"Round of 64"
|
||||
);
|
||||
|
||||
const targetMatch = roundOf64Matches.find((m) => m.matchNumber === targetMatchNumber);
|
||||
const targetMatch = roundOf64Matches.find((m) => m.matchNumber === r64MatchNumber);
|
||||
if (!targetMatch) {
|
||||
throw new Error(`Round of 64 match ${targetMatchNumber} not found`);
|
||||
throw new Error(`Round of 64 match ${r64MatchNumber} not found`);
|
||||
}
|
||||
|
||||
// Check if participant2Id is already filled
|
||||
if (targetMatch.participant2Id) {
|
||||
throw new Error(`Round of 64 match ${targetMatchNumber} participant2Id is already filled`);
|
||||
throw new Error(`Round of 64 match ${r64MatchNumber} participant2Id is already filled`);
|
||||
}
|
||||
|
||||
// Fill the slot
|
||||
await updatePlayoffMatch(targetMatch.id, { participant2Id: winnerId });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull } from "drizzle-orm";
|
||||
import type { BracketRegion } from "~/lib/bracket-templates";
|
||||
import { recalculateAffectedLeagues } from "./scoring-calculator";
|
||||
import { recalculateParticipantQP } from "./qualifying-points";
|
||||
|
||||
|
|
@ -36,6 +37,8 @@ export interface UpdateScoringEventData {
|
|||
completedAt?: Date;
|
||||
bracketTemplateId?: string;
|
||||
scoringStartsAtRound?: string;
|
||||
/** Per-event region config for NCAA-style brackets (overrides template defaults) */
|
||||
bracketRegionConfig?: BracketRegion[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -165,6 +168,7 @@ export async function updateScoringEvent(
|
|||
if (data.completedAt !== undefined) updateData.completedAt = data.completedAt;
|
||||
if (data.bracketTemplateId !== undefined) updateData.bracketTemplateId = data.bracketTemplateId;
|
||||
if (data.scoringStartsAtRound !== undefined) updateData.scoringStartsAtRound = data.scoringStartsAtRound;
|
||||
if (data.bracketRegionConfig !== undefined) updateData.bracketRegionConfig = data.bracketRegionConfig;
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.scoringEvents)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import {
|
|||
deleteOddsForParticipant,
|
||||
} from "~/models/playoff-match-odds";
|
||||
import { processPlayoffEvent } from "~/models/scoring-calculator";
|
||||
import { getBracketTemplate } from "~/lib/bracket-templates";
|
||||
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
|
||||
import {
|
||||
createGroupsForEvent,
|
||||
addMembersToGroup,
|
||||
|
|
@ -91,6 +91,38 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
return { error: "Invalid bracket template" };
|
||||
}
|
||||
|
||||
// Parse per-event region config for NCAA-style brackets
|
||||
let regionOverride: BracketRegion[] | undefined;
|
||||
if (template.regions && template.regions.length > 0) {
|
||||
const regions: BracketRegion[] = [];
|
||||
for (let r = 0; r < template.regions.length; r++) {
|
||||
const name = (formData.get(`regionName${r}`) as string | null)?.trim()
|
||||
|| `Region ${r + 1}`;
|
||||
const seedsRaw = (formData.get(`regionPlayInSeeds${r}`) as string | null) || "";
|
||||
const playInSeeds = seedsRaw
|
||||
.split(",")
|
||||
.map((s) => parseInt(s.trim(), 10))
|
||||
.filter((n) => !isNaN(n) && n >= 1 && n <= 16);
|
||||
const playInSet = new Set(playInSeeds);
|
||||
regions.push({
|
||||
name,
|
||||
directSeeds: ALL_16_SEEDS.filter((s) => !playInSet.has(s)),
|
||||
playIns: playInSeeds.map((s) => ({ seedSlot: s, teams: 2 as const })),
|
||||
});
|
||||
}
|
||||
// Validate total = template.totalTeams
|
||||
const total = regions.reduce(
|
||||
(sum, r) => sum + r.directSeeds.length + r.playIns.length * 2,
|
||||
0
|
||||
);
|
||||
if (total !== template.totalTeams) {
|
||||
return {
|
||||
error: `Region config accounts for ${total} team slots but template requires ${template.totalTeams}`,
|
||||
};
|
||||
}
|
||||
regionOverride = regions;
|
||||
}
|
||||
|
||||
const participantIds: string[] = [];
|
||||
|
||||
for (let i = 0; i < template.totalTeams; i++) {
|
||||
|
|
@ -108,7 +140,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
|
||||
try {
|
||||
await generateBracketFromTemplate(params.eventId, templateId, participantIds);
|
||||
await generateBracketFromTemplate(params.eventId, templateId, participantIds, regionOverride);
|
||||
|
||||
// PHASE 5.3: Mark participants NOT in the bracket as eliminated
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
|
|
@ -134,10 +166,11 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
console.log(`[BracketGeneration] Marked ${allParticipants.length - participantIds.length} participants as eliminated`);
|
||||
}
|
||||
|
||||
// Update the event to store the template ID and scoring start round
|
||||
// Update the event to store the template ID, scoring start round, and region config
|
||||
await updateScoringEvent(params.eventId, {
|
||||
bracketTemplateId: templateId,
|
||||
scoringStartsAtRound: template.scoringStartsAtRound,
|
||||
bracketRegionConfig: regionOverride,
|
||||
});
|
||||
|
||||
return { success: "Bracket generated successfully" };
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import {
|
|||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { ArrowLeft, Plus, Trophy, X, Check, ChevronDown, ChevronRight, Calendar, TrendingUp, Trash2 } from "lucide-react";
|
||||
import { getAllBracketTemplates, getBracketTemplate } from "~/lib/bracket-templates";
|
||||
import { getAllBracketTemplates, getBracketTemplate, buildNCAA68SlotMap, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -67,6 +67,84 @@ export default function EventBracket({
|
|||
|
||||
// Determine if this is a group-stage event
|
||||
const hasGroupStage = template?.groupStage != null;
|
||||
|
||||
// Determine if this template uses named regions (e.g. NCAA 68)
|
||||
const hasRegions = (template?.regions?.length ?? 0) > 0;
|
||||
|
||||
// Per-region configuration state: name + which seed slots are play-ins
|
||||
// Initialized from: stored event config → template defaults → empty
|
||||
const buildDefaultRegionConfig = (tmpl: typeof template) => {
|
||||
const source =
|
||||
(event.bracketRegionConfig as unknown as BracketRegion[] | null) ?? tmpl?.regions ?? [];
|
||||
return source.map((r) => ({
|
||||
name: r.name,
|
||||
playInSeeds: r.playIns.map((pi) => pi.seedSlot),
|
||||
}));
|
||||
};
|
||||
const [regionConfig, setRegionConfig] = useState(() => buildDefaultRegionConfig(template));
|
||||
|
||||
// Derive effective BracketRegion[] from regionConfig state
|
||||
const effectiveRegions: BracketRegion[] = useMemo(() => {
|
||||
return regionConfig.map((cfg) => {
|
||||
const playInSet = new Set(cfg.playInSeeds);
|
||||
return {
|
||||
name: cfg.name,
|
||||
directSeeds: ALL_16_SEEDS.filter((s) => !playInSet.has(s)),
|
||||
playIns: cfg.playInSeeds.map((s) => ({ seedSlot: s, teams: 2 as const })),
|
||||
};
|
||||
});
|
||||
}, [regionConfig]);
|
||||
|
||||
const regionSlotMap = useMemo(
|
||||
() => (effectiveRegions.length > 0 ? buildNCAA68SlotMap(effectiveRegions) : null),
|
||||
[effectiveRegions]
|
||||
);
|
||||
|
||||
// Region config handlers
|
||||
const updateRegionName = (regionIdx: number, name: string) => {
|
||||
setRegionConfig((prev) =>
|
||||
prev.map((r, i) => (i === regionIdx ? { ...r, name } : r))
|
||||
);
|
||||
};
|
||||
const addPlayIn = (regionIdx: number) => {
|
||||
setRegionConfig((prev) =>
|
||||
prev.map((r, i) => {
|
||||
if (i !== regionIdx || r.playInSeeds.length >= 2) return r;
|
||||
// Default to seed 16 if no play-ins yet, otherwise seed 11
|
||||
const defaultSeed = r.playInSeeds.length === 0 ? 16 : 11;
|
||||
const seed = r.playInSeeds.includes(defaultSeed)
|
||||
? ([11, 16].find((s) => !r.playInSeeds.includes(s)) ?? 11)
|
||||
: defaultSeed;
|
||||
return { ...r, playInSeeds: [...r.playInSeeds, seed] };
|
||||
})
|
||||
);
|
||||
// Reset participant selections since slot indices change
|
||||
setSelectedParticipants({});
|
||||
};
|
||||
const removePlayIn = (regionIdx: number, piIdx: number) => {
|
||||
setRegionConfig((prev) =>
|
||||
prev.map((r, i) =>
|
||||
i === regionIdx
|
||||
? { ...r, playInSeeds: r.playInSeeds.filter((_, j) => j !== piIdx) }
|
||||
: r
|
||||
)
|
||||
);
|
||||
setSelectedParticipants({});
|
||||
};
|
||||
const updatePlayInSeed = (regionIdx: number, piIdx: number, seed: number) => {
|
||||
setRegionConfig((prev) =>
|
||||
prev.map((r, i) =>
|
||||
i === regionIdx
|
||||
? {
|
||||
...r,
|
||||
playInSeeds: r.playInSeeds.map((s, j) => (j === piIdx ? seed : s)),
|
||||
}
|
||||
: r
|
||||
)
|
||||
);
|
||||
setSelectedParticipants({});
|
||||
};
|
||||
|
||||
const isGroupStageEvent = tournamentGroups.length > 0;
|
||||
|
||||
// Determine the phase for group-stage events
|
||||
|
|
@ -133,10 +211,12 @@ export default function EventBracket({
|
|||
}
|
||||
}, [actionData?.success]);
|
||||
|
||||
// Reset selected participants when template changes
|
||||
// Reset selected participants and region config when template changes
|
||||
const handleTemplateChange = (newTemplateId: string) => {
|
||||
setSelectedTemplate(newTemplateId);
|
||||
setSelectedParticipants({});
|
||||
const newTemplate = templates.find((t) => t.id === newTemplateId);
|
||||
setRegionConfig(buildDefaultRegionConfig(newTemplate));
|
||||
};
|
||||
|
||||
// Update selected participant for a seed
|
||||
|
|
@ -372,8 +452,172 @@ export default function EventBracket({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Non-group-stage template: show seeded list */}
|
||||
{!hasGroupStage && (
|
||||
{/* Non-group-stage template: region-grouped or flat seeded list */}
|
||||
{!hasGroupStage && hasRegions && regionSlotMap && (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* ── Region configuration ─────────────────────────────── */}
|
||||
<div className="border rounded-lg p-4 space-y-3">
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm">Configure Regions</h4>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Set each region's name and which seed slots are play-in games.
|
||||
</p>
|
||||
</div>
|
||||
{regionConfig.map((region, r) => (
|
||||
<div key={r} className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
className="w-36 h-8 text-sm"
|
||||
value={region.name}
|
||||
onChange={(e) => updateRegionName(r, e.target.value)}
|
||||
placeholder={`Region ${r + 1}`}
|
||||
/>
|
||||
{region.playInSeeds.map((seed, pi) => (
|
||||
<div key={pi} className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">play-in seed:</span>
|
||||
<Select
|
||||
value={String(seed)}
|
||||
onValueChange={(val) => updatePlayInSeed(r, pi, parseInt(val, 10))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-16 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ALL_16_SEEDS.map((s) => (
|
||||
<SelectItem key={s} value={String(s)}>{s}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground"
|
||||
onClick={() => removePlayIn(r, pi)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{region.playInSeeds.length < 2 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => addPlayIn(r)}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Add play-in
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* Hidden fields so server receives the region config */}
|
||||
{regionConfig.map((region, r) => (
|
||||
<Fragment key={r}>
|
||||
<input type="hidden" name={`regionName${r}`} value={region.name} />
|
||||
<input type="hidden" name={`regionPlayInSeeds${r}`} value={region.playInSeeds.join(",")} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Seed entry per region ─────────────────────────────── */}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select participants by region. Use search to find teams quickly.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{effectiveRegions.map((region, r) => {
|
||||
const directOffset = regionSlotMap.directOffsets[r];
|
||||
return (
|
||||
<div key={r} className="border rounded-lg p-4 space-y-2">
|
||||
<h4 className="font-semibold text-sm">{region.name || `Region ${r + 1}`}</h4>
|
||||
{region.directSeeds.map((seedNum, pos) => {
|
||||
const globalIdx = directOffset + pos;
|
||||
const opponentSeed = 17 - seedNum;
|
||||
const opponentIsPlayIn = region.playIns.some((pi) => pi.seedSlot === opponentSeed);
|
||||
return (
|
||||
<div key={seedNum} className="flex items-center gap-2">
|
||||
<Label className="w-16 text-xs text-muted-foreground shrink-0">
|
||||
Seed {seedNum}
|
||||
</Label>
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="hidden"
|
||||
name={`participant${globalIdx}`}
|
||||
value={selectedParticipants[globalIdx] || ""}
|
||||
required
|
||||
/>
|
||||
<ParticipantSelector
|
||||
participants={getAvailableParticipants(globalIdx)}
|
||||
value={selectedParticipants[globalIdx] || ""}
|
||||
onValueChange={(value) => handleParticipantChange(globalIdx, value)}
|
||||
placeholder={`Seed ${seedNum}…`}
|
||||
/>
|
||||
</div>
|
||||
{opponentIsPlayIn && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">← faces play-in</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{region.playIns.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground pt-1 border-t">
|
||||
{region.playIns.map((pi) => `Seed ${pi.seedSlot}`).join(" & ")} via play-in ↓
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── Play-in team entry ─────────────────────────────────── */}
|
||||
{regionSlotMap.playInOffsets.length > 0 && (
|
||||
<div className="border rounded-lg p-4 space-y-4">
|
||||
<h4 className="font-semibold text-sm">Play-In Teams</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter both teams for each play-in game. The winner advances to the main bracket.
|
||||
</p>
|
||||
{regionSlotMap.playInOffsets.map((pi, ffIdx) => {
|
||||
const regionName = effectiveRegions[pi.regionIndex]?.name || `Region ${pi.regionIndex + 1}`;
|
||||
const idx1 = pi.startIndex;
|
||||
const idx2 = pi.startIndex + 1;
|
||||
return (
|
||||
<div key={ffIdx} className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Play-In {ffIdx + 1} — {regionName} {pi.seedSlot}-seed
|
||||
</p>
|
||||
{[idx1, idx2].map((globalIdx, slot) => (
|
||||
<div key={globalIdx} className="flex items-center gap-2">
|
||||
<Label className="w-16 text-xs text-muted-foreground shrink-0">
|
||||
Team {slot + 1}
|
||||
</Label>
|
||||
<div className="flex-1 min-w-0">
|
||||
<input
|
||||
type="hidden"
|
||||
name={`participant${globalIdx}`}
|
||||
value={selectedParticipants[globalIdx] || ""}
|
||||
required
|
||||
/>
|
||||
<ParticipantSelector
|
||||
participants={getAvailableParticipants(globalIdx)}
|
||||
value={selectedParticipants[globalIdx] || ""}
|
||||
onValueChange={(value) => handleParticipantChange(globalIdx, value)}
|
||||
placeholder={`${regionName} ${pi.seedSlot}-seed play-in team ${slot + 1}…`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Non-group-stage, non-region template: flat seeded list */}
|
||||
{!hasGroupStage && !hasRegions && (
|
||||
<div className="space-y-2">
|
||||
<Label>Select Participants (in order)</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { integer, pgTable, varchar, uuid, timestamp, pgEnum, boolean, text, date, decimal, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { integer, pgTable, varchar, uuid, timestamp, pgEnum, boolean, text, date, decimal, uniqueIndex, jsonb } from "drizzle-orm/pg-core";
|
||||
import { relations } from "drizzle-orm";
|
||||
|
||||
// Users table - synced from Clerk
|
||||
|
|
@ -351,6 +351,8 @@ export const scoringEvents = pgTable("scoring_events", {
|
|||
// 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.
|
||||
// Per-event region config for NCAA-style brackets (overrides template defaults)
|
||||
bracketRegionConfig: jsonb("bracket_region_config"), // BracketRegion[] | null
|
||||
isComplete: boolean("is_complete").notNull().default(false),
|
||||
completedAt: timestamp("completed_at"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
|
|
|
|||
1
drizzle/0043_demonic_vanisher.sql
Normal file
1
drizzle/0043_demonic_vanisher.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "scoring_events" ADD COLUMN "bracket_region_config" jsonb;
|
||||
3534
drizzle/meta/0043_snapshot.json
Normal file
3534
drizzle/meta/0043_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -302,6 +302,13 @@
|
|||
"when": 1773600000000,
|
||||
"tag": "0042_migrate_event_date_to_starts_at",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 43,
|
||||
"version": "7",
|
||||
"when": 1773634394633,
|
||||
"tag": "0043_demonic_vanisher",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
62
package-lock.json
generated
62
package-lock.json
generated
|
|
@ -76,7 +76,7 @@
|
|||
"dotenv-cli": "^8.0.0",
|
||||
"esbuild": "^0.25.11",
|
||||
"jsdom": "^27.0.1",
|
||||
"shadcn": "^4.0.5",
|
||||
"shadcn": "^4.0.8",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"tsx": "^4.20.6",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
|
@ -107,35 +107,6 @@
|
|||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@antfu/ni": {
|
||||
"version": "25.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@antfu/ni/-/ni-25.0.0.tgz",
|
||||
"integrity": "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansis": "^4.0.0",
|
||||
"fzf": "^0.5.2",
|
||||
"package-manager-detector": "^1.3.0",
|
||||
"tinyexec": "^1.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"na": "bin/na.mjs",
|
||||
"nci": "bin/nci.mjs",
|
||||
"ni": "bin/ni.mjs",
|
||||
"nlx": "bin/nlx.mjs",
|
||||
"nr": "bin/nr.mjs",
|
||||
"nun": "bin/nun.mjs",
|
||||
"nup": "bin/nup.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@antfu/ni/node_modules/tinyexec": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz",
|
||||
"integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@asamuzakjp/css-color": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.0.5.tgz",
|
||||
|
|
@ -6641,16 +6612,6 @@
|
|||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansis": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz",
|
||||
"integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/arch": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz",
|
||||
|
|
@ -9918,13 +9879,6 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fzf": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fzf/-/fzf-0.5.2.tgz",
|
||||
"integrity": "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
|
|
@ -12490,13 +12444,6 @@
|
|||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/package-manager-detector": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.5.0.tgz",
|
||||
"integrity": "sha512-uBj69dVlYe/+wxj8JOpr97XfsxH/eumMt6HqjNTmJDf/6NO9s+0uxeOneIz3AsPt2m6y9PqzDzd3ATcU17MNfw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
|
|
@ -13728,13 +13675,12 @@
|
|||
"license": "ISC"
|
||||
},
|
||||
"node_modules/shadcn": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/shadcn/-/shadcn-4.0.5.tgz",
|
||||
"integrity": "sha512-z0SOHEU1+ADam1UJHrgxJhUsOb0/jBoYc+u9mhWs071KrnORq48X7uCwG3mD2ysQEBtOfeK/MxMGsmzL5Jt+Jg==",
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/shadcn/-/shadcn-4.0.8.tgz",
|
||||
"integrity": "sha512-DVAyeo95TQ/OvaHugLm5V0Dqz3al+dnoP3mZdWWxKJ33IYG1jN5B3sGZyNaYsfzm7JsWokfksSzDl83LnmMing==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@antfu/ni": "^25.0.0",
|
||||
"@babel/core": "^7.28.0",
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/plugin-transform-typescript": "^7.28.0",
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@
|
|||
"dotenv-cli": "^8.0.0",
|
||||
"esbuild": "^0.25.11",
|
||||
"jsdom": "^27.0.1",
|
||||
"shadcn": "^4.0.5",
|
||||
"shadcn": "^4.0.8",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"tsx": "^4.20.6",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue