Add per-event configurable region config for NCAA-style brackets (#149) (#149)

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:
Chris Parsons 2026-03-15 21:52:47 -07:00 committed by GitHub
parent 67c7de1924
commit 0277c4d25e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 4298 additions and 221 deletions

View file

@ -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 116 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 (116) */
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):
* [015] East seeds 116
* [1630] South seeds 115
* [3145] West seeds 110, 1216
* [4659] Midwest seeds 110, 1215
* [6061] South 16-seed play-in (2 teams)
* [6263] West 11-seed play-in (2 teams)
* [6465] Midwest 11-seed play-in (2 teams)
* [6667] 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 115 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 110 and 1216 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 110 and 1215 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 },
],
},
],
};
/**

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
ALTER TABLE "scoring_events" ADD COLUMN "bracket_region_config" jsonb;

File diff suppressed because it is too large Load diff

View file

@ -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
View file

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

View file

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