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[];
|
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 {
|
export interface BracketTemplate {
|
||||||
/** Unique identifier for this template */
|
/** Unique identifier for this template */
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -40,6 +71,90 @@ export interface BracketTemplate {
|
||||||
groupStage?: GroupStageConfig;
|
groupStage?: GroupStageConfig;
|
||||||
/** Number of teams advancing to knockout stage (when groupStage is defined) */
|
/** Number of teams advancing to knockout stage (when groupStage is defined) */
|
||||||
knockoutTeams?: number;
|
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)
|
* NCAA March Madness (68 teams)
|
||||||
* First Four (play-in) → Round of 64 → Round of 32 → Sweet Sixteen → Elite Eight → Final Four → Championship
|
* 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
|
* 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 = {
|
export const NCAA_68: BracketTemplate = {
|
||||||
id: "ncaa_68",
|
id: "ncaa_68",
|
||||||
|
|
@ -236,6 +367,35 @@ export const NCAA_68: BracketTemplate = {
|
||||||
isScoring: true, // Winner 1st, Loser 2nd
|
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 { 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
|
* 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);
|
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 { eq, and } from "drizzle-orm";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import type { BracketTemplate } from "~/lib/bracket-templates";
|
import type { BracketTemplate, BracketRegion } from "~/lib/bracket-templates";
|
||||||
import { getBracketTemplate } from "~/lib/bracket-templates";
|
import {
|
||||||
|
getBracketTemplate,
|
||||||
|
buildNCAA68SlotMap,
|
||||||
|
matchIndexForSeedSlot,
|
||||||
|
STANDARD_BRACKET_SEEDING,
|
||||||
|
} from "~/lib/bracket-templates";
|
||||||
|
|
||||||
export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
|
export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
|
||||||
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
|
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
|
||||||
|
|
@ -256,87 +261,6 @@ export async function advanceWinner(
|
||||||
await updatePlayoffMatch(nextMatch.id, { [participantSlot]: winnerId });
|
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
|
* Generate standard tournament seeding matchups
|
||||||
* Returns pairs of seed indices for proper bracket balance
|
* Returns pairs of seed indices for proper bracket balance
|
||||||
|
|
@ -395,7 +319,8 @@ function generateStandardSeeding(teamCount: number): [number, number][] {
|
||||||
export async function generateBracketFromTemplate(
|
export async function generateBracketFromTemplate(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
templateId: string,
|
templateId: string,
|
||||||
participantIds?: string[]
|
participantIds?: string[],
|
||||||
|
regionOverride?: BracketRegion[]
|
||||||
): Promise<PlayoffMatch[]> {
|
): Promise<PlayoffMatch[]> {
|
||||||
const template = getBracketTemplate(templateId);
|
const template = getBracketTemplate(templateId);
|
||||||
if (!template) {
|
if (!template) {
|
||||||
|
|
@ -415,8 +340,12 @@ export async function generateBracketFromTemplate(
|
||||||
}
|
}
|
||||||
|
|
||||||
// NCAA 68 requires special handling for First Four and Round of 64
|
// 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") {
|
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
|
// 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
|
* Generate NCAA 68 bracket using the template's regions config.
|
||||||
* Phase 2.8: Special handling for First Four play-in games
|
*
|
||||||
|
* 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(
|
async function generateNCAA68Bracket(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
template: BracketTemplate,
|
template: BracketTemplate,
|
||||||
participantIds?: string[]
|
participantIds?: string[]
|
||||||
): Promise<PlayoffMatch[]> {
|
): 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 matches: NewPlayoffMatch[] = [];
|
||||||
const firstFourSeeding = generateNCAA68FirstFourSeeding();
|
const slotMap = buildNCAA68SlotMap(template.regions);
|
||||||
for (let i = 0; i < firstFourSeeding.length; i++) {
|
|
||||||
const [seed1, seed2] = firstFourSeeding[i];
|
// 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({
|
matches.push({
|
||||||
scoringEventId: eventId,
|
scoringEventId: eventId,
|
||||||
round: "First Four",
|
round: "First Four",
|
||||||
matchNumber: i + 1,
|
matchNumber: i + 1,
|
||||||
participant1Id: participantIds ? participantIds[seed1] : null,
|
participant1Id: participantIds ? (participantIds[startIndex] ?? null) : null,
|
||||||
participant2Id: participantIds ? participantIds[seed2] : null,
|
participant2Id: participantIds ? (participantIds[startIndex + 1] ?? null) : null,
|
||||||
isComplete: false,
|
isComplete: false,
|
||||||
isScoring: false, // First Four doesn't score fantasy points
|
isScoring: false,
|
||||||
templateRound: "First Four",
|
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
|
// ── Round of 64: 8 matches per region ────────────────────────────────────
|
||||||
const roundOf64Seeding = generateNCAA68RoundOf64Seeding();
|
let r64MatchNumber = 1;
|
||||||
for (let i = 0; i < roundOf64Seeding.length; i++) {
|
for (let r = 0; r < template.regions.length; r++) {
|
||||||
const matchup = roundOf64Seeding[i];
|
const region = template.regions[r];
|
||||||
let participant1Id: string | null = null;
|
const directOffset = slotMap.directOffsets[r];
|
||||||
let participant2Id: string | null = null;
|
const labels = regionFFLabels.get(r) ?? [];
|
||||||
let seedInfo: string | null = null;
|
|
||||||
|
|
||||||
if (participantIds) {
|
// Map seed rank → participant array index or FF label
|
||||||
const [seed1, seed2] = matchup;
|
const seedToSlot = new Map<number, number | string>();
|
||||||
|
for (let i = 0; i < region.directSeeds.length; i++) {
|
||||||
// Handle TBD slots (FF1-FF4 placeholders)
|
seedToSlot.set(region.directSeeds[i], directOffset + i);
|
||||||
if (typeof seed1 === "number") {
|
}
|
||||||
participant1Id = participantIds[seed1];
|
for (let p = 0; p < region.playIns.length; p++) {
|
||||||
} // else it's "FF1", "FF2", etc. - will be filled by First Four winner
|
seedToSlot.set(region.playIns[p].seedSlot, labels[p]);
|
||||||
|
|
||||||
if (typeof seed2 === "number") {
|
|
||||||
participant2Id = participantIds[seed2];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
seedInfo = `${typeof seed1 === "number" ? seed1 + 1 : seed1} vs ${typeof seed2 === "number" ? seed2 + 1 : seed2}`;
|
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({
|
matches.push({
|
||||||
scoringEventId: eventId,
|
scoringEventId: eventId,
|
||||||
round: "Round of 64",
|
round: "Round of 64",
|
||||||
matchNumber: i + 1,
|
matchNumber: r64MatchNumber++,
|
||||||
participant1Id,
|
participant1Id:
|
||||||
participant2Id,
|
participantIds && typeof slot1 === "number"
|
||||||
|
? (participantIds[slot1] ?? null)
|
||||||
|
: null,
|
||||||
|
participant2Id:
|
||||||
|
participantIds && typeof slot2 === "number"
|
||||||
|
? (participantIds[slot2] ?? null)
|
||||||
|
: null,
|
||||||
isComplete: false,
|
isComplete: false,
|
||||||
isScoring: false, // Round of 64 doesn't score
|
isScoring: false,
|
||||||
templateRound: "Round of 64",
|
templateRound: "Round of 64",
|
||||||
seedInfo,
|
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++) {
|
for (let roundIndex = 2; roundIndex < template.rounds.length; roundIndex++) {
|
||||||
const round = template.rounds[roundIndex];
|
const round = template.rounds[roundIndex];
|
||||||
for (let i = 0; i < round.matchCount; i++) {
|
for (let i = 0; i < round.matchCount; i++) {
|
||||||
|
|
@ -931,9 +886,9 @@ export async function advanceWinnerTemplate(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special handling for NCAA 68 First Four
|
// 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") {
|
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
|
// Find current round in template
|
||||||
|
|
@ -986,49 +941,58 @@ export async function advanceWinnerTemplate(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Advance First Four winner to Round of 64
|
* Advance a First Four winner to their target Round of 64 slot.
|
||||||
* Phase 2.8: NCAA 68 specific logic
|
|
||||||
*
|
*
|
||||||
* First Four match → Round of 64 match mapping:
|
* The target match number is computed dynamically from the template's regions config:
|
||||||
* - FF Match 1 (16-seed) → R64 Match 1 (Region 1 vs #1 seed), participant2Id
|
* r64MatchNumber = regionIndex * 8 + matchIndexForSeedSlot(seedSlot) + 1
|
||||||
* - 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
|
* The winner always fills participant2Id (they are the lower seed in their matchup).
|
||||||
* - FF Match 4 (11-seed) → R64 Match 29 (Region 4 vs #6 seed), participant2Id
|
|
||||||
*/
|
*/
|
||||||
async function advanceFirstFourWinner(
|
async function advanceFirstFourWinner(
|
||||||
firstFourMatch: PlayoffMatch,
|
firstFourMatch: PlayoffMatch,
|
||||||
winnerId: string
|
winnerId: string,
|
||||||
|
template: BracketTemplate
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Map First Four match numbers to Round of 64 match numbers
|
// Prefer the per-event stored region config (set at bracket generation time);
|
||||||
const firstFourToRoundOf64: Record<number, number> = {
|
// fall back to the template's built-in regions for backwards compatibility.
|
||||||
1: 1, // FF Game 1 (16-seed) → R64 Match 1 (vs 1 seed)
|
const db = database();
|
||||||
2: 9, // FF Game 2 (16-seed) → R64 Match 9 (vs 1 seed)
|
const eventRow = await db.query.scoringEvents.findFirst({
|
||||||
3: 21, // FF Game 3 (11-seed) → R64 Match 21 (vs 6 seed)
|
where: eq(schema.scoringEvents.id, firstFourMatch.scoringEventId),
|
||||||
4: 29, // FF Game 4 (11-seed) → R64 Match 29 (vs 6 seed)
|
columns: { bracketRegionConfig: true },
|
||||||
};
|
});
|
||||||
|
const regions =
|
||||||
|
(eventRow?.bracketRegionConfig as BracketRegion[] | null) ?? template.regions;
|
||||||
|
|
||||||
const targetMatchNumber = firstFourToRoundOf64[firstFourMatch.matchNumber];
|
if (!regions) {
|
||||||
if (!targetMatchNumber) {
|
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}`);
|
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(
|
const roundOf64Matches = await findPlayoffMatchesByEventIdAndRound(
|
||||||
firstFourMatch.scoringEventId,
|
firstFourMatch.scoringEventId,
|
||||||
"Round of 64"
|
"Round of 64"
|
||||||
);
|
);
|
||||||
|
|
||||||
const targetMatch = roundOf64Matches.find((m) => m.matchNumber === targetMatchNumber);
|
const targetMatch = roundOf64Matches.find((m) => m.matchNumber === r64MatchNumber);
|
||||||
if (!targetMatch) {
|
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) {
|
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 });
|
await updatePlayoffMatch(targetMatch.id, { participant2Id: winnerId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull } from "drizzle-orm";
|
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 { recalculateAffectedLeagues } from "./scoring-calculator";
|
||||||
import { recalculateParticipantQP } from "./qualifying-points";
|
import { recalculateParticipantQP } from "./qualifying-points";
|
||||||
|
|
||||||
|
|
@ -36,6 +37,8 @@ export interface UpdateScoringEventData {
|
||||||
completedAt?: Date;
|
completedAt?: Date;
|
||||||
bracketTemplateId?: string;
|
bracketTemplateId?: string;
|
||||||
scoringStartsAtRound?: 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.completedAt !== undefined) updateData.completedAt = data.completedAt;
|
||||||
if (data.bracketTemplateId !== undefined) updateData.bracketTemplateId = data.bracketTemplateId;
|
if (data.bracketTemplateId !== undefined) updateData.bracketTemplateId = data.bracketTemplateId;
|
||||||
if (data.scoringStartsAtRound !== undefined) updateData.scoringStartsAtRound = data.scoringStartsAtRound;
|
if (data.scoringStartsAtRound !== undefined) updateData.scoringStartsAtRound = data.scoringStartsAtRound;
|
||||||
|
if (data.bracketRegionConfig !== undefined) updateData.bracketRegionConfig = data.bracketRegionConfig;
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(schema.scoringEvents)
|
.update(schema.scoringEvents)
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import {
|
||||||
deleteOddsForParticipant,
|
deleteOddsForParticipant,
|
||||||
} from "~/models/playoff-match-odds";
|
} from "~/models/playoff-match-odds";
|
||||||
import { processPlayoffEvent } from "~/models/scoring-calculator";
|
import { processPlayoffEvent } from "~/models/scoring-calculator";
|
||||||
import { getBracketTemplate } from "~/lib/bracket-templates";
|
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
|
||||||
import {
|
import {
|
||||||
createGroupsForEvent,
|
createGroupsForEvent,
|
||||||
addMembersToGroup,
|
addMembersToGroup,
|
||||||
|
|
@ -91,6 +91,38 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
return { error: "Invalid bracket template" };
|
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[] = [];
|
const participantIds: string[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < template.totalTeams; i++) {
|
for (let i = 0; i < template.totalTeams; i++) {
|
||||||
|
|
@ -108,7 +140,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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
|
// PHASE 5.3: Mark participants NOT in the bracket as eliminated
|
||||||
const event = await getScoringEventById(params.eventId);
|
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`);
|
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, {
|
await updateScoringEvent(params.eventId, {
|
||||||
bracketTemplateId: templateId,
|
bracketTemplateId: templateId,
|
||||||
scoringStartsAtRound: template.scoringStartsAtRound,
|
scoringStartsAtRound: template.scoringStartsAtRound,
|
||||||
|
bracketRegionConfig: regionOverride,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: "Bracket generated successfully" };
|
return { success: "Bracket generated successfully" };
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "~/components/ui/select";
|
} from "~/components/ui/select";
|
||||||
import { ArrowLeft, Plus, Trophy, X, Check, ChevronDown, ChevronRight, Calendar, TrendingUp, Trash2 } from "lucide-react";
|
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 {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -67,6 +67,84 @@ export default function EventBracket({
|
||||||
|
|
||||||
// Determine if this is a group-stage event
|
// Determine if this is a group-stage event
|
||||||
const hasGroupStage = template?.groupStage != null;
|
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;
|
const isGroupStageEvent = tournamentGroups.length > 0;
|
||||||
|
|
||||||
// Determine the phase for group-stage events
|
// Determine the phase for group-stage events
|
||||||
|
|
@ -133,10 +211,12 @@ export default function EventBracket({
|
||||||
}
|
}
|
||||||
}, [actionData?.success]);
|
}, [actionData?.success]);
|
||||||
|
|
||||||
// Reset selected participants when template changes
|
// Reset selected participants and region config when template changes
|
||||||
const handleTemplateChange = (newTemplateId: string) => {
|
const handleTemplateChange = (newTemplateId: string) => {
|
||||||
setSelectedTemplate(newTemplateId);
|
setSelectedTemplate(newTemplateId);
|
||||||
setSelectedParticipants({});
|
setSelectedParticipants({});
|
||||||
|
const newTemplate = templates.find((t) => t.id === newTemplateId);
|
||||||
|
setRegionConfig(buildDefaultRegionConfig(newTemplate));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update selected participant for a seed
|
// Update selected participant for a seed
|
||||||
|
|
@ -372,8 +452,172 @@ export default function EventBracket({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Non-group-stage template: show seeded list */}
|
{/* Non-group-stage template: region-grouped or flat seeded list */}
|
||||||
{!hasGroupStage && (
|
{!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">
|
<div className="space-y-2">
|
||||||
<Label>Select Participants (in order)</Label>
|
<Label>Select Participants (in order)</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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";
|
import { relations } from "drizzle-orm";
|
||||||
|
|
||||||
// Users table - synced from Clerk
|
// Users table - synced from Clerk
|
||||||
|
|
@ -351,6 +351,8 @@ export const scoringEvents = pgTable("scoring_events", {
|
||||||
// Template system (Phase 2.6)
|
// Template system (Phase 2.6)
|
||||||
bracketTemplateId: varchar("bracket_template_id", { length: 50 }), // "ncaa_68", "nfl_14", "simple_16", etc.
|
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.
|
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),
|
isComplete: boolean("is_complete").notNull().default(false),
|
||||||
completedAt: timestamp("completed_at"),
|
completedAt: timestamp("completed_at"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
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,
|
"when": 1773600000000,
|
||||||
"tag": "0042_migrate_event_date_to_starts_at",
|
"tag": "0042_migrate_event_date_to_starts_at",
|
||||||
"breakpoints": true
|
"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",
|
"dotenv-cli": "^8.0.0",
|
||||||
"esbuild": "^0.25.11",
|
"esbuild": "^0.25.11",
|
||||||
"jsdom": "^27.0.1",
|
"jsdom": "^27.0.1",
|
||||||
"shadcn": "^4.0.5",
|
"shadcn": "^4.0.8",
|
||||||
"tailwindcss": "^4.1.4",
|
"tailwindcss": "^4.1.4",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
|
@ -107,35 +107,6 @@
|
||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.0.5.tgz",
|
"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"
|
"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": {
|
"node_modules/arch": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz",
|
||||||
|
|
@ -9918,13 +9879,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/gensync": {
|
||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||||
|
|
@ -12490,13 +12444,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0"
|
"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": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
|
|
@ -13728,13 +13675,12 @@
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/shadcn": {
|
"node_modules/shadcn": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/shadcn/-/shadcn-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/shadcn/-/shadcn-4.0.8.tgz",
|
||||||
"integrity": "sha512-z0SOHEU1+ADam1UJHrgxJhUsOb0/jBoYc+u9mhWs071KrnORq48X7uCwG3mD2ysQEBtOfeK/MxMGsmzL5Jt+Jg==",
|
"integrity": "sha512-DVAyeo95TQ/OvaHugLm5V0Dqz3al+dnoP3mZdWWxKJ33IYG1jN5B3sGZyNaYsfzm7JsWokfksSzDl83LnmMing==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@antfu/ni": "^25.0.0",
|
|
||||||
"@babel/core": "^7.28.0",
|
"@babel/core": "^7.28.0",
|
||||||
"@babel/parser": "^7.28.0",
|
"@babel/parser": "^7.28.0",
|
||||||
"@babel/plugin-transform-typescript": "^7.28.0",
|
"@babel/plugin-transform-typescript": "^7.28.0",
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@
|
||||||
"dotenv-cli": "^8.0.0",
|
"dotenv-cli": "^8.0.0",
|
||||||
"esbuild": "^0.25.11",
|
"esbuild": "^0.25.11",
|
||||||
"jsdom": "^27.0.1",
|
"jsdom": "^27.0.1",
|
||||||
"shadcn": "^4.0.5",
|
"shadcn": "^4.0.8",
|
||||||
"tailwindcss": "^4.1.4",
|
"tailwindcss": "^4.1.4",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue