Compare commits
1 commit
main
...
claude/coo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5fea001f1 |
197 changed files with 3566 additions and 40444 deletions
|
|
@ -9,14 +9,17 @@
|
|||
"command": ".claude/hooks/lint-on-edit.sh",
|
||||
"timeout": 30,
|
||||
"statusMessage": "Linting..."
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"if": "Write(*.ts)|Write(*.tsx)|Edit(*.ts)|Edit(*.tsx)|MultiEdit(*.ts)|MultiEdit(*.tsx)",
|
||||
"command": "output=$(npm run typecheck 2>&1); rc=$?; if [ $rc -ne 0 ]; then printf '{\"hookSpecificOutput\":{\"hookEventName\":\"PostToolUse\",\"additionalContext\":\"TypeCheck failed:\\n%s\"}}' \"$(echo \"$output\" | tail -30 | sed 's/\"/\\\\\"/g; s/$/\\\\n/' | tr -d '\\n')\"; fi",
|
||||
"timeout": 60,
|
||||
"statusMessage": "Type-checking...",
|
||||
"async": true
|
||||
"command": "which react-router > /dev/null 2>&1 || exit 0; output=$(npm run typecheck 2>&1); rc=$?; if [ $rc -ne 0 ]; then printf '{\"systemMessage\":\"TypeCheck failed:\\n%s\"}' \"$(echo \"$output\" | tail -30 | sed 's/\"/\\\\\"/g; s/$/\\\\n/' | tr -d '\\n')\"; fi",
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,8 +31,3 @@ CLOUDINARY_API_SECRET=""
|
|||
# Must match the CRON_SECRET repo secret in Forgejo.
|
||||
# Generate with: openssl rand -hex 32
|
||||
CRON_SECRET=""
|
||||
|
||||
# OC Blacktop motorsport API — used to sync IndyCar championship standings
|
||||
# (fresher than ESPN's aggregate). Free tier at https://ocblacktop.com/api.
|
||||
# If unset, IndyCar standings fall back to ESPN.
|
||||
OCBLACKTOP_API_KEY=""
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const scoringRules = {
|
|||
};
|
||||
|
||||
describe("QualifyingPointsStandings", () => {
|
||||
it("renders fractional QP to at most hundredths with trailing zeros trimmed", () => {
|
||||
it("renders fractional QP to hundredths", () => {
|
||||
render(
|
||||
<QualifyingPointsStandings
|
||||
standings={[
|
||||
|
|
@ -52,8 +52,7 @@ describe("QualifyingPointsStandings", () => {
|
|||
);
|
||||
|
||||
expect(screen.getByText("14.67 QP")).toBeInTheDocument();
|
||||
// 0.50 trims its trailing zero to 0.5; 14.67 and 0.43 are unaffected.
|
||||
expect(screen.getByText("0.5 QP")).toBeInTheDocument();
|
||||
expect(screen.getByText("0.50 QP")).toBeInTheDocument();
|
||||
expect(screen.getByText("0.43 QP")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,236 +0,0 @@
|
|||
import { Link } from "react-router";
|
||||
import {
|
||||
addMonths,
|
||||
differenceInCalendarDays,
|
||||
eachMonthOfInterval,
|
||||
format,
|
||||
parseISO,
|
||||
} from "date-fns";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import type { DraftScheduleWindow } from "~/models/sports-season";
|
||||
|
||||
export interface GanttSport {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
iconUrl: string | null;
|
||||
windows: DraftScheduleWindow[];
|
||||
}
|
||||
|
||||
interface DraftScheduleGanttProps {
|
||||
sports: GanttSport[];
|
||||
/** Horizon start (today) as a YYYY-MM-DD string. */
|
||||
today: string;
|
||||
/** Number of months the timeline spans. */
|
||||
months: number;
|
||||
}
|
||||
|
||||
// Bar color by sport-season status, using the theme chart tokens from app.css.
|
||||
const STATUS_COLORS: Record<DraftScheduleWindow["status"], string> = {
|
||||
active: "var(--chart-1)",
|
||||
upcoming: "var(--chart-2)",
|
||||
completed: "var(--muted-foreground)",
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<DraftScheduleWindow["status"], string> = {
|
||||
active: "Active",
|
||||
upcoming: "Upcoming",
|
||||
completed: "Completed",
|
||||
};
|
||||
|
||||
const clampPct = (n: number) => Math.max(0, Math.min(100, n));
|
||||
|
||||
// Row layout (px). A single-lane row is LANE_HEIGHT + ROW_V_PAD tall; extra
|
||||
// concurrent windows add one lane each so overlapping bars never stack on top
|
||||
// of one another.
|
||||
const LANE_HEIGHT = 30;
|
||||
const LANE_GAP = 8;
|
||||
const ROW_V_PAD = 14;
|
||||
|
||||
const rowHeight = (laneCount: number) => Math.max(laneCount, 1) * LANE_HEIGHT + ROW_V_PAD;
|
||||
|
||||
/**
|
||||
* Greedy interval-scheduling: assign each window to the first lane whose last
|
||||
* bar ends before this one starts, otherwise open a new lane. Windows arrive
|
||||
* sorted by draftOn (see findDraftScheduleForHorizon).
|
||||
*/
|
||||
function assignLanes(
|
||||
windows: DraftScheduleWindow[],
|
||||
start: Date
|
||||
): { placed: Array<{ window: DraftScheduleWindow; lane: number }>; laneCount: number } {
|
||||
const laneEnds: number[] = [];
|
||||
const placed = windows.map((window) => {
|
||||
const startDay = differenceInCalendarDays(parseISO(window.draftOn), start);
|
||||
const endDay = differenceInCalendarDays(parseISO(window.draftOff), start);
|
||||
let lane = laneEnds.findIndex((laneEnd) => laneEnd < startDay);
|
||||
if (lane === -1) {
|
||||
lane = laneEnds.length;
|
||||
laneEnds.push(endDay);
|
||||
} else {
|
||||
laneEnds[lane] = endDay;
|
||||
}
|
||||
return { window, lane };
|
||||
});
|
||||
return { placed, laneCount: Math.max(laneEnds.length, 1) };
|
||||
}
|
||||
|
||||
export function DraftScheduleGantt({ sports, today, months }: DraftScheduleGanttProps) {
|
||||
const start = parseISO(today);
|
||||
const end = addMonths(start, months);
|
||||
const totalDays = Math.max(differenceInCalendarDays(end, start), 1);
|
||||
|
||||
const pct = (date: Date) => (differenceInCalendarDays(date, start) / totalDays) * 100;
|
||||
|
||||
// Month-boundary gridlines that fall inside the horizon.
|
||||
const monthLines = eachMonthOfInterval({ start, end })
|
||||
.map((date) => ({ date, left: pct(date) }))
|
||||
.filter((m) => m.left >= 0 && m.left <= 100);
|
||||
|
||||
// Pre-compute lane assignment + height for each sport row.
|
||||
const rows = sports.map((sport) => {
|
||||
const { placed, laneCount } = assignLanes(sport.windows, start);
|
||||
return { sport, placed, height: rowHeight(laneCount) };
|
||||
});
|
||||
|
||||
if (sports.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Draft Schedule</CardTitle>
|
||||
<CardDescription>Draft windows across the next {months} months</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>No sports found.</p>
|
||||
<p className="text-sm mt-2">Create a sport to start scheduling draft windows.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Draft Schedule</CardTitle>
|
||||
<CardDescription>
|
||||
Draft windows across the next {months} months (each bar spans a
|
||||
sport-season’s draft-on → draft-off window)
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
{(Object.keys(STATUS_COLORS) as DraftScheduleWindow["status"][]).map((status) => (
|
||||
<div key={status} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-3 w-3 rounded-sm"
|
||||
style={{ backgroundColor: STATUS_COLORS[status] }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{STATUS_LABELS[status]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="flex" style={{ minWidth: 160 + months * 80 }}>
|
||||
{/* Sport labels column */}
|
||||
<div className="w-40 shrink-0">
|
||||
<div className="h-8" aria-hidden="true" />
|
||||
{rows.map(({ sport, height }) => (
|
||||
<div
|
||||
key={sport.id}
|
||||
style={{ height }}
|
||||
className="flex items-center border-b border-border/50 pr-2"
|
||||
>
|
||||
<span className="truncate text-sm font-medium" title={sport.name}>
|
||||
{sport.name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Timeline column */}
|
||||
<div className="relative flex-1">
|
||||
{/* Month gridlines (full height) */}
|
||||
{monthLines.map((m) => (
|
||||
<div
|
||||
key={`line-${m.date.toISOString()}`}
|
||||
className="pointer-events-none absolute bottom-0 top-0 w-px bg-border/60"
|
||||
style={{ left: `${m.left}%` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
{/* Today marker (full height) */}
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-0 top-0 w-0.5 bg-primary/80"
|
||||
style={{ left: 0 }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Month labels header */}
|
||||
<div className="relative h-8">
|
||||
{monthLines.map((m) => (
|
||||
<span
|
||||
key={`label-${m.date.toISOString()}`}
|
||||
className="absolute top-1 text-xs text-muted-foreground"
|
||||
style={{ left: `calc(${m.left}% + 4px)` }}
|
||||
>
|
||||
{format(m.date, "MMM yyyy")}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
{rows.map(({ sport, placed, height }) => (
|
||||
<div
|
||||
key={sport.id}
|
||||
style={{ height }}
|
||||
className="relative border-b border-border/50"
|
||||
>
|
||||
{placed.length === 0 ? (
|
||||
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-xs italic text-muted-foreground/70">
|
||||
No draft window
|
||||
</span>
|
||||
) : (
|
||||
placed.map(({ window: w, lane }) => {
|
||||
const left = clampPct(pct(parseISO(w.draftOn)));
|
||||
const right = clampPct(pct(parseISO(w.draftOff)));
|
||||
const width = Math.max(right - left, 0.75);
|
||||
return (
|
||||
<Link
|
||||
key={w.id}
|
||||
to={`/admin/sports-seasons/${w.id}`}
|
||||
className="absolute flex items-center overflow-hidden rounded px-1.5 text-xs font-medium text-background transition-opacity hover:opacity-80"
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
width: `${width}%`,
|
||||
top: ROW_V_PAD / 2 + lane * LANE_HEIGHT,
|
||||
height: LANE_HEIGHT - LANE_GAP,
|
||||
backgroundColor: STATUS_COLORS[w.status],
|
||||
}}
|
||||
title={`${w.name} (${w.year}) · ${w.draftOn} → ${w.draftOff}`}
|
||||
>
|
||||
<span className="truncate">{w.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { DraftScheduleGantt, type GanttSport } from "../DraftScheduleGantt";
|
||||
|
||||
const nbaWindow = {
|
||||
id: "ss-1",
|
||||
name: "2026 NBA Playoffs",
|
||||
year: 2026,
|
||||
status: "upcoming" as const,
|
||||
draftOn: "2026-08-01",
|
||||
draftOff: "2026-09-15",
|
||||
sport: { id: "nba", name: "NBA", slug: "nba", iconUrl: null },
|
||||
};
|
||||
|
||||
function renderGantt(sports: GanttSport[], months = 6) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<DraftScheduleGantt sports={sports} today="2026-07-02" months={months} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
describe("DraftScheduleGantt", () => {
|
||||
it("renders empty state when there are no sports", () => {
|
||||
renderGantt([]);
|
||||
expect(screen.getByText("No sports found.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a bar linking to the sport-season for each draft window", () => {
|
||||
renderGantt([
|
||||
{ id: "nba", name: "NBA", slug: "nba", iconUrl: null, windows: [nbaWindow] },
|
||||
]);
|
||||
|
||||
const bar = screen.getByRole("link", { name: /2026 NBA Playoffs/ });
|
||||
expect(bar).toHaveAttribute("href", "/admin/sports-seasons/ss-1");
|
||||
});
|
||||
|
||||
it("renders overlapping windows as separate, clickable bars", () => {
|
||||
const overlappingWindow = {
|
||||
id: "ss-2",
|
||||
name: "2027 NBA Playoffs",
|
||||
year: 2027,
|
||||
status: "active" as const,
|
||||
draftOn: "2026-08-15",
|
||||
draftOff: "2026-10-01",
|
||||
sport: { id: "nba", name: "NBA", slug: "nba", iconUrl: null },
|
||||
};
|
||||
|
||||
renderGantt([
|
||||
{
|
||||
id: "nba",
|
||||
name: "NBA",
|
||||
slug: "nba",
|
||||
iconUrl: null,
|
||||
windows: [nbaWindow, overlappingWindow],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(
|
||||
screen.getByRole("link", { name: /2026 NBA Playoffs/ })
|
||||
).toHaveAttribute("href", "/admin/sports-seasons/ss-1");
|
||||
expect(
|
||||
screen.getByRole("link", { name: /2027 NBA Playoffs/ })
|
||||
).toHaveAttribute("href", "/admin/sports-seasons/ss-2");
|
||||
});
|
||||
|
||||
it("shows a 'No draft window' hint for a sport with no windows", () => {
|
||||
renderGantt([
|
||||
{ id: "golf", name: "Golf", slug: "golf", iconUrl: null, windows: [] },
|
||||
]);
|
||||
|
||||
expect(screen.getByText("Golf")).toBeInTheDocument();
|
||||
expect(screen.getByText("No draft window")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the status legend", () => {
|
||||
renderGantt([
|
||||
{ id: "nba", name: "NBA", slug: "nba", iconUrl: null, windows: [nbaWindow] },
|
||||
]);
|
||||
|
||||
expect(screen.getByText("Active")).toBeInTheDocument();
|
||||
expect(screen.getByText("Upcoming")).toBeInTheDocument();
|
||||
expect(screen.getByText("Completed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reflects the horizon length in the description", () => {
|
||||
renderGantt(
|
||||
[{ id: "nba", name: "NBA", slug: "nba", iconUrl: null, windows: [nbaWindow] }],
|
||||
12
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Draft windows across the next 12 months/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -118,23 +118,20 @@ function ActiveRow({
|
|||
</div>
|
||||
{/* Stats — second row on mobile, right side on desktop */}
|
||||
{showStats && (
|
||||
<div className="flex items-start gap-4 border-t border-border/50 pt-2 sm:border-0 sm:pt-0 sm:shrink-0">
|
||||
<div className="flex items-center gap-4 border-t border-border/50 pt-2 sm:border-0 sm:pt-0 sm:shrink-0">
|
||||
{displayRank !== undefined && (
|
||||
<RankingDisplay
|
||||
displayRank={displayRank}
|
||||
rankChange={previousRank !== undefined && currentRank !== undefined && previousRank !== currentRank ? previousRank - currentRank : undefined}
|
||||
/>
|
||||
)}
|
||||
{currentRank !== undefined && totalPoints !== undefined && <StatDivider className="h-8" />}
|
||||
{currentRank !== undefined && totalPoints !== undefined && <StatDivider />}
|
||||
{totalPoints !== undefined && (
|
||||
<StatColumn
|
||||
label="Points"
|
||||
value={
|
||||
<span className="text-2xl font-bold leading-none text-electric">
|
||||
{Math.round(totalPoints).toLocaleString("en-US")}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<StatColumn label="Points">
|
||||
<span className="text-2xl font-bold leading-none text-electric">
|
||||
{Math.round(totalPoints).toLocaleString("en-US")}
|
||||
</span>
|
||||
</StatColumn>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -172,21 +169,17 @@ function PreDraftRow({
|
|||
</div>
|
||||
{/* Stats — second row on mobile, right side on desktop */}
|
||||
<div className="flex items-center gap-4 border-t border-border/50 pt-2 sm:border-0 sm:pt-0 sm:shrink-0">
|
||||
<StatColumn
|
||||
label="Draft"
|
||||
value={<span className="text-2xl font-bold leading-none">{draftTimeValue}</span>}
|
||||
/>
|
||||
<StatColumn label="Draft">
|
||||
<span className="text-2xl font-bold leading-none">{draftTimeValue}</span>
|
||||
</StatColumn>
|
||||
{draftPosition !== undefined && (
|
||||
<>
|
||||
<StatDivider className="h-8" />
|
||||
<StatColumn
|
||||
label="Position"
|
||||
value={
|
||||
<span className="text-2xl font-bold leading-none">
|
||||
{ordinal(draftPosition)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<StatDivider />
|
||||
<StatColumn label="Position">
|
||||
<span className="text-2xl font-bold leading-none">
|
||||
{ordinal(draftPosition)}
|
||||
</span>
|
||||
</StatColumn>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -137,52 +137,6 @@ export const FullLeague: Story = {
|
|||
},
|
||||
};
|
||||
|
||||
export const WithProjections: Story = {
|
||||
args: {
|
||||
showProjected: true,
|
||||
entries: [
|
||||
{
|
||||
teamId: "t1",
|
||||
teamName: "Lightning Wolves",
|
||||
ownerName: "alice",
|
||||
displayRank: 1,
|
||||
currentRank: 1,
|
||||
points: 2810,
|
||||
rankChange: 2,
|
||||
pointChange: 87.5,
|
||||
projectedPoints: 3120,
|
||||
participantsRemaining: 3,
|
||||
href: "/leagues/1/standings/1/teams/t1",
|
||||
},
|
||||
{
|
||||
teamId: "t2",
|
||||
teamName: "Shadow Hawks",
|
||||
ownerName: "bob",
|
||||
displayRank: 2,
|
||||
currentRank: 2,
|
||||
points: 2654.5,
|
||||
rankChange: -1,
|
||||
pointChange: 42.0,
|
||||
projectedPoints: 2980,
|
||||
participantsRemaining: 2,
|
||||
href: "/leagues/1/standings/1/teams/t2",
|
||||
},
|
||||
{
|
||||
teamId: "t3",
|
||||
teamName: "Iron Eagles",
|
||||
ownerName: "carol",
|
||||
displayRank: 3,
|
||||
currentRank: 3,
|
||||
points: 2493,
|
||||
// No 7-day change and no live participants — placeholders keep alignment.
|
||||
projectedPoints: 2493,
|
||||
participantsRemaining: 0,
|
||||
href: "/leagues/1/standings/1/teams/t3",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithTies: Story = {
|
||||
args: {
|
||||
entries: [
|
||||
|
|
|
|||
|
|
@ -5,17 +5,7 @@ import { Card, CardContent, CardHeader } from "~/components/ui/card";
|
|||
import { GradientIcon } from "~/components/ui/GradientIcon";
|
||||
import { TeamAvatar } from "~/components/TeamAvatar";
|
||||
import type { AvatarData, RawFlagConfig } from "~/lib/flag-types";
|
||||
import { StatColumn, StatDivider, DeltaBadge, RankingDisplay } from "./StatHelpers";
|
||||
|
||||
/** Mirrors PointsDisplay gating: only project while the team still has participants live. */
|
||||
function hasProjection(entry: StandingsPreviewEntry): boolean {
|
||||
return (
|
||||
entry.projectedPoints !== null &&
|
||||
entry.projectedPoints !== undefined &&
|
||||
entry.participantsRemaining !== undefined &&
|
||||
entry.participantsRemaining > 0
|
||||
);
|
||||
}
|
||||
import { StatColumn, StatDivider, PointChangeIndicator, RankingDisplay } from "./StatHelpers";
|
||||
|
||||
// ─── Row styles ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -33,20 +23,7 @@ function rowClasses(currentRank: number | undefined, hasHref: boolean): string {
|
|||
|
||||
// ─── Row content ──────────────────────────────────────────────────────────────
|
||||
|
||||
function RowContent({
|
||||
entry,
|
||||
showProjected,
|
||||
}: {
|
||||
entry: StandingsPreviewEntry;
|
||||
showProjected: boolean;
|
||||
}) {
|
||||
// Only reserve the delta line when this row actually changed. A row with no rank
|
||||
// or point movement drops the line entirely (no "—" placeholders); rows that moved
|
||||
// keep aligned columns by showing "—" for whichever stat didn't change.
|
||||
const rowHasChange =
|
||||
(entry.rankChange !== undefined && entry.rankChange !== 0) ||
|
||||
(entry.pointChange !== undefined && entry.pointChange !== 0);
|
||||
|
||||
function RowContent({ entry }: { entry: StandingsPreviewEntry }) {
|
||||
return (
|
||||
<>
|
||||
{/* Left: avatar + name */}
|
||||
|
|
@ -69,44 +46,16 @@ function RowContent({
|
|||
|
||||
{/* Right: stats — second row on mobile */}
|
||||
<div className="flex items-center gap-4 w-full border-t border-border/30 pt-2 mt-1 sm:w-auto sm:border-0 sm:pt-0 sm:mt-0 sm:shrink-0">
|
||||
<RankingDisplay
|
||||
displayRank={entry.displayRank}
|
||||
rankChange={entry.rankChange}
|
||||
reserveDelta={rowHasChange}
|
||||
/>
|
||||
{showProjected && <StatDivider />}
|
||||
{showProjected && (
|
||||
<StatColumn
|
||||
label="Projected"
|
||||
reserveDelta={rowHasChange}
|
||||
value={
|
||||
hasProjection(entry) ? (
|
||||
<span className="text-2xl font-normal leading-none text-muted-foreground">
|
||||
{Math.round(entry.projectedPoints as number).toLocaleString("en-US")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-2xl font-normal leading-none text-muted-foreground/40">
|
||||
—
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<RankingDisplay displayRank={entry.displayRank} rankChange={entry.rankChange} />
|
||||
<StatDivider />
|
||||
<StatColumn
|
||||
label="Points"
|
||||
reserveDelta={rowHasChange}
|
||||
value={
|
||||
<span className="text-2xl font-bold leading-none text-electric">
|
||||
{Math.round(entry.points).toLocaleString("en-US")}
|
||||
</span>
|
||||
}
|
||||
delta={
|
||||
entry.pointChange !== undefined && entry.pointChange !== 0 ? (
|
||||
<DeltaBadge delta={entry.pointChange} kind="points" />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<StatColumn label="Points">
|
||||
<span className="text-2xl font-bold leading-none text-electric">
|
||||
{Math.round(entry.points).toLocaleString("en-US")}
|
||||
</span>
|
||||
{entry.pointChange !== undefined && entry.pointChange !== 0 && (
|
||||
<PointChangeIndicator delta={entry.pointChange} />
|
||||
)}
|
||||
</StatColumn>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
@ -132,26 +81,15 @@ export interface StandingsPreviewEntry {
|
|||
rankChange?: number;
|
||||
/** 7-day point delta. From TeamStanding.sevenDayPointChange. */
|
||||
pointChange?: number;
|
||||
/** Projected final points. From TeamStanding.projectedPoints. */
|
||||
projectedPoints?: number | null;
|
||||
/** Live participants left; projection only shown while > 0. */
|
||||
participantsRemaining?: number;
|
||||
}
|
||||
|
||||
export interface StandingsPreviewProps {
|
||||
entries: StandingsPreviewEntry[];
|
||||
description?: string;
|
||||
fullStandingsHref?: string;
|
||||
/** When true, adds a "Projected" column (projected final points). Default false. */
|
||||
showProjected?: boolean;
|
||||
}
|
||||
|
||||
export function StandingsPreview({
|
||||
entries,
|
||||
description,
|
||||
fullStandingsHref,
|
||||
showProjected = false,
|
||||
}: StandingsPreviewProps) {
|
||||
export function StandingsPreview({ entries, description, fullStandingsHref }: StandingsPreviewProps) {
|
||||
return (
|
||||
<Card className="gap-2">
|
||||
<CardHeader className="px-3 sm:px-6 pb-2">
|
||||
|
|
@ -181,11 +119,11 @@ export function StandingsPreview({
|
|||
to={entry.href}
|
||||
className={rowClasses(entry.currentRank, true)}
|
||||
>
|
||||
<RowContent entry={entry} showProjected={showProjected} />
|
||||
<RowContent entry={entry} />
|
||||
</Link>
|
||||
) : (
|
||||
<div key={entry.teamId} className={rowClasses(entry.currentRank, false)}>
|
||||
<RowContent entry={entry} showProjected={showProjected} />
|
||||
<RowContent entry={entry} />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,71 +1,30 @@
|
|||
/**
|
||||
* Stacked stat column: label / value / delta. The delta slot is always rendered
|
||||
* (a muted "—" placeholder when there is no change) so that every row reserves the
|
||||
* same vertical space and the columns line up across the standings table.
|
||||
*/
|
||||
export function StatColumn({
|
||||
label,
|
||||
value,
|
||||
delta,
|
||||
reserveDelta = false,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
/** Optional change indicator rendered on its own line below the value. */
|
||||
delta?: React.ReactNode;
|
||||
/**
|
||||
* When true, the delta line is always rendered (a muted placeholder when there is
|
||||
* no delta) so columns stay vertically aligned across rows. When false and no delta
|
||||
* is provided, the line is omitted entirely.
|
||||
*/
|
||||
reserveDelta?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const showDeltaLine = delta !== undefined || reserveDelta;
|
||||
return (
|
||||
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<div className="flex items-baseline justify-end">{value}</div>
|
||||
{showDeltaLine && (
|
||||
<div className="flex items-baseline justify-end leading-none mt-0.5">
|
||||
{delta ?? <span className="text-xs text-muted-foreground/60">—</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-baseline justify-end gap-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatDivider({ className = "h-12" }: { className?: string } = {}) {
|
||||
return <div className={`${className} w-px bg-border shrink-0 self-center`} />;
|
||||
export function StatDivider() {
|
||||
return <div className="h-8 w-px bg-border shrink-0 self-center" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 7-day change indicator. `rank` deltas are unitless places; `points` deltas are
|
||||
* rounded point totals. Positive = up (green ▲), negative = down (coral ▼).
|
||||
*/
|
||||
export function DeltaBadge({
|
||||
delta,
|
||||
kind,
|
||||
}: {
|
||||
delta: number;
|
||||
kind: "rank" | "points";
|
||||
}) {
|
||||
const magnitude = kind === "rank" ? Math.abs(delta) : Math.abs(Math.round(delta));
|
||||
const up = delta > 0;
|
||||
const label =
|
||||
kind === "rank"
|
||||
? up
|
||||
? `up ${magnitude}`
|
||||
: `down ${magnitude}`
|
||||
: up
|
||||
? `+${magnitude} points`
|
||||
: `-${magnitude} points`;
|
||||
|
||||
if (up) {
|
||||
export function RankChangeIndicator({ delta }: { delta: number }) {
|
||||
if (delta === 0) return null;
|
||||
if (delta > 0) {
|
||||
return (
|
||||
<span className="text-xs font-semibold text-primary" aria-label={label}>
|
||||
▲{magnitude}
|
||||
<span className="text-xs font-semibold text-primary" aria-label={`up ${delta}`}>
|
||||
▲{delta}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -73,9 +32,9 @@ export function DeltaBadge({
|
|||
<span
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: "var(--coral-accent, #ef4444)" }}
|
||||
aria-label={label}
|
||||
aria-label={`down ${Math.abs(delta)}`}
|
||||
>
|
||||
▼{magnitude}
|
||||
▼{Math.abs(delta)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -83,22 +42,35 @@ export function DeltaBadge({
|
|||
export function RankingDisplay({
|
||||
displayRank,
|
||||
rankChange,
|
||||
reserveDelta = false,
|
||||
}: {
|
||||
displayRank: string | number;
|
||||
rankChange?: number;
|
||||
reserveDelta?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<StatColumn
|
||||
label="Ranking"
|
||||
reserveDelta={reserveDelta}
|
||||
value={<span className="text-2xl font-bold leading-none">{displayRank}</span>}
|
||||
delta={
|
||||
rankChange !== undefined && rankChange !== 0 ? (
|
||||
<DeltaBadge delta={rankChange} kind="rank" />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<StatColumn label="Ranking">
|
||||
<span className="text-2xl font-bold leading-none">{displayRank}</span>
|
||||
{rankChange !== undefined && rankChange !== 0 && (
|
||||
<RankChangeIndicator delta={rankChange} />
|
||||
)}
|
||||
</StatColumn>
|
||||
);
|
||||
}
|
||||
|
||||
export function PointChangeIndicator({ delta }: { delta: number }) {
|
||||
if (delta >= 0) {
|
||||
return (
|
||||
<span className="text-xs font-semibold text-primary" aria-label={`+${Math.round(delta)} points`}>
|
||||
▲{Math.round(delta)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="text-xs font-semibold"
|
||||
style={{ color: "var(--coral-accent, #ef4444)" }}
|
||||
aria-label={`${Math.round(delta)} points`}
|
||||
>
|
||||
▼{Math.abs(Math.round(delta))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,54 +103,4 @@ describe("StandingsPreview", () => {
|
|||
expect(link).toHaveAttribute("href", "/leagues/1/standings/1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Projections", () => {
|
||||
it("does not render a Projected column by default", () => {
|
||||
const entry: StandingsPreviewEntry = {
|
||||
...baseEntry,
|
||||
projectedPoints: 3000,
|
||||
participantsRemaining: 3,
|
||||
};
|
||||
renderWithRouter(<StandingsPreview entries={[entry]} />);
|
||||
|
||||
expect(screen.queryByText("Projected")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("3,000")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the projected value when showProjected and participants remain", () => {
|
||||
const entry: StandingsPreviewEntry = {
|
||||
...baseEntry,
|
||||
projectedPoints: 3000,
|
||||
participantsRemaining: 3,
|
||||
};
|
||||
renderWithRouter(<StandingsPreview entries={[entry]} showProjected />);
|
||||
|
||||
expect(screen.getByText("Projected")).toBeInTheDocument();
|
||||
expect(screen.getByText("3,000")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a placeholder instead of a value when no participants remain", () => {
|
||||
const entry: StandingsPreviewEntry = {
|
||||
...baseEntry,
|
||||
projectedPoints: 9999,
|
||||
participantsRemaining: 0,
|
||||
};
|
||||
renderWithRouter(<StandingsPreview entries={[entry]} showProjected />);
|
||||
|
||||
// Column header still present, but the projected total is not shown.
|
||||
expect(screen.getByText("Projected")).toBeInTheDocument();
|
||||
expect(screen.queryByText("9,999")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a placeholder when projectedPoints is missing", () => {
|
||||
const entry: StandingsPreviewEntry = {
|
||||
...baseEntry,
|
||||
projectedPoints: null,
|
||||
participantsRemaining: 3,
|
||||
};
|
||||
renderWithRouter(<StandingsPreview entries={[entry]} showProjected />);
|
||||
|
||||
expect(screen.getByText("Projected")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import type { ComponentType } from "react";
|
||||
import type { LucideProps } from "lucide-react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type SettingsNavSection = {
|
||||
|
|
@ -18,37 +17,10 @@ export type SettingsGridSection = SettingsNavSection & {
|
|||
export function SettingsMobileGridNav({
|
||||
sections,
|
||||
onSectionChange,
|
||||
buildHref,
|
||||
}: {
|
||||
sections: readonly SettingsGridSection[];
|
||||
onSectionChange?: (sectionId: string) => void;
|
||||
buildHref?: (sectionId: string) => string;
|
||||
onSectionChange: (sectionId: string) => void;
|
||||
}) {
|
||||
const cardClassName = (section: SettingsGridSection) =>
|
||||
cn(
|
||||
"flex cursor-pointer flex-col gap-3 rounded-xl border bg-card p-4 text-left transition-colors hover:border-primary/40 active:scale-[0.98]",
|
||||
section.isDanger && "border-destructive/40"
|
||||
);
|
||||
|
||||
const cardInner = (section: SettingsGridSection) => (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-lg",
|
||||
section.isDanger
|
||||
? "bg-destructive/15 text-destructive"
|
||||
: "bg-primary/20 text-primary"
|
||||
)}
|
||||
>
|
||||
<section.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold leading-tight">{section.label}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{section.subtitle}</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mb-5 lg:hidden">
|
||||
<div className="mb-3">
|
||||
|
|
@ -57,22 +29,32 @@ export function SettingsMobileGridNav({
|
|||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{sections.map((section) =>
|
||||
buildHref ? (
|
||||
<Link key={section.id} to={buildHref(section.id)} className={cardClassName(section)}>
|
||||
{cardInner(section)}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
onClick={() => onSectionChange?.(section.id)}
|
||||
className={cardClassName(section)}
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
onClick={() => onSectionChange(section.id)}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col gap-3 rounded-xl border bg-card p-4 text-left transition-colors hover:border-primary/40 active:scale-[0.98]",
|
||||
section.isDanger && "border-destructive/40"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-lg",
|
||||
section.isDanger
|
||||
? "bg-destructive/15 text-destructive"
|
||||
: "bg-primary/20 text-primary"
|
||||
)}
|
||||
>
|
||||
{cardInner(section)}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<section.icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold leading-tight">{section.label}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{section.subtitle}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -80,30 +62,19 @@ export function SettingsMobileGridNav({
|
|||
|
||||
export function SettingsMobileSectionPill({
|
||||
onShowGrid,
|
||||
backHref,
|
||||
}: {
|
||||
onShowGrid?: () => void;
|
||||
backHref?: string;
|
||||
onShowGrid: () => void;
|
||||
}) {
|
||||
const className =
|
||||
"flex cursor-pointer items-center gap-1.5 rounded-full border bg-card px-3 py-1.5 text-sm font-medium hover:bg-muted";
|
||||
const content = (
|
||||
<>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back to all settings
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<div className="mb-5 lg:hidden">
|
||||
{backHref ? (
|
||||
<Link to={backHref} className={className}>
|
||||
{content}
|
||||
</Link>
|
||||
) : (
|
||||
<button type="button" onClick={onShowGrid} className={className}>
|
||||
{content}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShowGrid}
|
||||
className="flex cursor-pointer items-center gap-1.5 rounded-full border bg-card px-3 py-1.5 text-sm font-medium hover:bg-muted"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back to all settings
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -112,57 +83,33 @@ export function SettingsDesktopNav({
|
|||
sections,
|
||||
activeSection,
|
||||
onSectionChange,
|
||||
buildHref,
|
||||
navLabel = "League settings",
|
||||
}: {
|
||||
sections: readonly SettingsGridSection[];
|
||||
activeSection: string;
|
||||
onSectionChange?: (sectionId: string) => void;
|
||||
buildHref?: (sectionId: string) => string;
|
||||
navLabel?: string;
|
||||
onSectionChange: (sectionId: string) => void;
|
||||
}) {
|
||||
const itemClassName = (section: SettingsGridSection) =>
|
||||
cn(
|
||||
"flex w-full cursor-pointer items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
activeSection === section.id && "bg-muted text-foreground"
|
||||
);
|
||||
|
||||
const itemInner = (section: SettingsGridSection) => (
|
||||
<>
|
||||
<section.icon className={cn("h-4 w-4 shrink-0", section.isDanger && "text-destructive")} aria-hidden="true" />
|
||||
{section.label}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:block">
|
||||
<div className="sticky top-6 rounded-xl border bg-card p-3">
|
||||
<p className="px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Manage
|
||||
</p>
|
||||
<nav aria-label={navLabel} className="space-y-1">
|
||||
{sections.map((section) =>
|
||||
buildHref ? (
|
||||
<Link
|
||||
key={section.id}
|
||||
to={buildHref(section.id)}
|
||||
aria-current={activeSection === section.id ? "page" : undefined}
|
||||
className={itemClassName(section)}
|
||||
>
|
||||
{itemInner(section)}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
onClick={() => onSectionChange?.(section.id)}
|
||||
aria-current={activeSection === section.id ? "page" : undefined}
|
||||
className={itemClassName(section)}
|
||||
>
|
||||
{itemInner(section)}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<nav aria-label="League settings" className="space-y-1">
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
onClick={() => onSectionChange(section.id)}
|
||||
aria-current={activeSection === section.id ? "page" : undefined}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
activeSection === section.id && "bg-muted text-foreground"
|
||||
)}
|
||||
>
|
||||
<section.icon className={cn("h-4 w-4 shrink-0", section.isDanger && "text-destructive")} aria-hidden="true" />
|
||||
{section.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
|
|
|||
|
|
@ -1,67 +0,0 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { Bell, User } from "lucide-react";
|
||||
import {
|
||||
SettingsDesktopNav,
|
||||
SettingsMobileGridNav,
|
||||
SettingsMobileSectionPill,
|
||||
type SettingsGridSection,
|
||||
} from "../SettingsNavigation";
|
||||
|
||||
const sections: readonly SettingsGridSection[] = [
|
||||
{ id: "profile", label: "Profile", icon: User, subtitle: "Name, avatar" },
|
||||
{ id: "notifications", label: "Notifications", icon: Bell, subtitle: "Email & Discord" },
|
||||
];
|
||||
|
||||
describe("SettingsNavigation link mode", () => {
|
||||
it("renders desktop nav entries as anchors built from buildHref", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsDesktopNav
|
||||
sections={sections}
|
||||
activeSection="profile"
|
||||
buildHref={(id) => `/settings/${id}`}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("link", { name: "Profile" })).toHaveAttribute("href", "/settings/profile");
|
||||
expect(screen.getByRole("link", { name: "Notifications" })).toHaveAttribute(
|
||||
"href",
|
||||
"/settings/notifications"
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Profile" })).toHaveAttribute("aria-current", "page");
|
||||
});
|
||||
|
||||
it("renders the mobile grid as anchors and the back pill as an anchor", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SettingsMobileGridNav sections={sections} buildHref={(id) => `/settings/${id}`} />
|
||||
<SettingsMobileSectionPill backHref="/settings" />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("link", { name: /Profile/i })).toHaveAttribute("href", "/settings/profile");
|
||||
expect(screen.getByRole("link", { name: /back to all settings/i })).toHaveAttribute(
|
||||
"href",
|
||||
"/settings"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SettingsNavigation button mode (league settings)", () => {
|
||||
it("still fires onSectionChange when no buildHref is provided", () => {
|
||||
const onSectionChange = vi.fn();
|
||||
render(
|
||||
<SettingsDesktopNav
|
||||
sections={sections}
|
||||
activeSection="profile"
|
||||
onSectionChange={onSectionChange}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Notifications" }));
|
||||
expect(onSectionChange).toHaveBeenCalledWith("notifications");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,16 +1,13 @@
|
|||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { useRoundTransition } from "~/hooks/useRoundTransition";
|
||||
import type { FeederMap } from "~/lib/bracket-layout";
|
||||
import type { BracketTemplate } from "~/lib/bracket-templates";
|
||||
import {
|
||||
TreeColumns,
|
||||
BracketMatchSlot,
|
||||
bracketGeometry,
|
||||
windowGeometry,
|
||||
SLOT_WIDTH,
|
||||
LABEL_HEIGHT,
|
||||
DESIRED_CARD_HEIGHT,
|
||||
CARD_GAP,
|
||||
MAX_CARD_HEIGHT,
|
||||
type BracketMatch,
|
||||
type BracketOwnership,
|
||||
|
|
@ -24,8 +21,6 @@ interface BracketTreePaginatedProps {
|
|||
/** Index of the first scoring round — default page starts here */
|
||||
firstScoringRoundIdx?: number;
|
||||
thirdPlaceRound?: string;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
}
|
||||
|
||||
export function BracketTreePaginated({
|
||||
|
|
@ -35,68 +30,63 @@ export function BracketTreePaginated({
|
|||
userParticipantIds,
|
||||
firstScoringRoundIdx,
|
||||
thirdPlaceRound,
|
||||
feeders,
|
||||
template,
|
||||
}: BracketTreePaginatedProps) {
|
||||
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
||||
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
||||
|
||||
// Pages are pairs of layout columns, not pairs of rounds: a column can mix rounds
|
||||
// when teams enter the bracket at different points (see computeGroupLayout).
|
||||
const geometry = bracketGeometry(
|
||||
mainRounds,
|
||||
matchesByRound,
|
||||
feeders,
|
||||
template?.rounds.map((r) => r.name) ?? mainRounds
|
||||
);
|
||||
const columns = geometry.layout.columns;
|
||||
const lastPage = Math.max(columns.length - 2, 0);
|
||||
|
||||
const defaultPage = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
firstScoringRoundIdx !== undefined ? Math.max(0, firstScoringRoundIdx - 1) : lastPage,
|
||||
lastPage,
|
||||
firstScoringRoundIdx !== undefined
|
||||
? Math.max(0, firstScoringRoundIdx - 1)
|
||||
: mainRounds.length - 2,
|
||||
mainRounds.length - 2,
|
||||
),
|
||||
);
|
||||
|
||||
const { page, anim, stripRef, navigate, handleTransitionEnd } = useRoundTransition(
|
||||
lastPage,
|
||||
mainRounds.length - 2,
|
||||
defaultPage,
|
||||
);
|
||||
|
||||
const pageGeometry = (p: number) => windowGeometry(geometry, p, p + 1);
|
||||
const labelFor = (p: number) => {
|
||||
const [a, b] = [columns[p]?.label, columns[p + 1]?.label];
|
||||
return b ? `${a} → ${b}` : (a ?? "");
|
||||
const targetPage = anim ? anim.toPage : page;
|
||||
const labelRounds = mainRounds.slice(targetPage, targetPage + 2);
|
||||
const label = labelRounds[1] ? `${labelRounds[0]} → ${labelRounds[1]}` : labelRounds[0];
|
||||
|
||||
const calcHeight = (p: number) => {
|
||||
const rs = mainRounds.slice(p, p + 2);
|
||||
const max = Math.max(...rs.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||||
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
||||
};
|
||||
|
||||
const label = labelFor(anim ? anim.toPage : page);
|
||||
const pageHeight = calcHeight(page);
|
||||
const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight;
|
||||
const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight;
|
||||
|
||||
const pageG = pageGeometry(page);
|
||||
const animFromG = anim ? pageGeometry(anim.fromPage) : pageG;
|
||||
const animToG = anim ? pageGeometry(anim.toPage) : pageG;
|
||||
const visibleRounds = mainRounds.slice(page, page + 2);
|
||||
const fromRounds = anim ? mainRounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds;
|
||||
const toRounds = anim ? mainRounds.slice(anim.toPage, anim.toPage + 2) : visibleRounds;
|
||||
|
||||
let leftPage: number;
|
||||
let rightPage: number | null = null;
|
||||
let leftG = pageG;
|
||||
let rightG = pageG;
|
||||
let leftRounds: string[];
|
||||
let rightRounds: string[] = [];
|
||||
let leftHeight: number;
|
||||
let rightHeight = 0;
|
||||
let settlingTransition = false;
|
||||
if (anim?.phase === "sliding") {
|
||||
leftPage = anim.dir === "right" ? anim.fromPage : anim.toPage;
|
||||
rightPage = anim.dir === "right" ? anim.toPage : anim.fromPage;
|
||||
leftG = anim.dir === "right" ? animFromG : animToG;
|
||||
rightG = anim.dir === "right" ? animToG : animFromG;
|
||||
leftRounds = anim.dir === "right" ? fromRounds : toRounds;
|
||||
rightRounds = anim.dir === "right" ? toRounds : fromRounds;
|
||||
leftHeight = anim.dir === "right" ? animFromHeight : animToHeight;
|
||||
rightHeight = anim.dir === "right" ? animToHeight : animFromHeight;
|
||||
} else if (anim?.phase === "settling") {
|
||||
leftPage = anim.toPage;
|
||||
leftG = animToG;
|
||||
leftRounds = toRounds;
|
||||
leftHeight = animToHeight;
|
||||
settlingTransition = true;
|
||||
} else {
|
||||
leftPage = page;
|
||||
leftRounds = visibleRounds;
|
||||
leftHeight = pageHeight;
|
||||
}
|
||||
|
||||
const containerMinHeight =
|
||||
anim?.phase === "settling" ? animToG.bracketHeight : animFromG.bracketHeight;
|
||||
const containerMinHeight = anim?.phase === "settling" ? animToHeight : animFromHeight;
|
||||
const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0;
|
||||
|
||||
return (
|
||||
|
|
@ -119,7 +109,7 @@ export function BracketTreePaginated({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(page + 1)}
|
||||
disabled={page >= lastPage || !!anim}
|
||||
disabled={page + 2 >= mainRounds.length || !!anim}
|
||||
className="h-7 w-7 shrink-0"
|
||||
aria-label="Next rounds"
|
||||
>
|
||||
|
|
@ -139,24 +129,22 @@ export function BracketTreePaginated({
|
|||
>
|
||||
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||
<TreeColumns
|
||||
geometry={leftG}
|
||||
columnRange={[leftPage, leftPage + 1]}
|
||||
visibleRounds={leftRounds}
|
||||
matchesByRound={matchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
bracketHeight={leftHeight}
|
||||
transitionDuration={settlingTransition ? 500 : undefined}
|
||||
/>
|
||||
</div>
|
||||
{anim?.phase === "sliding" && rightPage !== null && (
|
||||
{anim?.phase === "sliding" && (
|
||||
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||
<TreeColumns
|
||||
geometry={rightG}
|
||||
columnRange={[rightPage, rightPage + 1]}
|
||||
visibleRounds={rightRounds}
|
||||
matchesByRound={matchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
bracketHeight={rightHeight}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -176,8 +164,6 @@ export function BracketTreePaginated({
|
|||
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,5 @@
|
|||
import { avatarColor } from "~/lib/avatar-colors";
|
||||
import { BRACKT_GRADIENT } from "~/lib/brand";
|
||||
import {
|
||||
computeGroupLayout,
|
||||
describeSlotSource,
|
||||
matchKey,
|
||||
type BracketLayout,
|
||||
type FeederMap,
|
||||
} from "~/lib/bracket-layout";
|
||||
import type { BracketTemplate } from "~/lib/bracket-templates";
|
||||
|
||||
export interface BracketMatch {
|
||||
id: string;
|
||||
|
|
@ -54,8 +46,6 @@ function formatScore(score: string | null): string | null {
|
|||
|
||||
interface ParticipantRowProps {
|
||||
name: string | null;
|
||||
/** What fills this slot when it's still empty, e.g. "Winner of Winners SF 2". */
|
||||
feedLabel?: string | null;
|
||||
isTbd: boolean;
|
||||
isWinner: boolean;
|
||||
isLoser: boolean;
|
||||
|
|
@ -70,7 +60,6 @@ interface ParticipantRowProps {
|
|||
|
||||
function ParticipantRow({
|
||||
name,
|
||||
feedLabel,
|
||||
isTbd,
|
||||
isWinner,
|
||||
isLoser,
|
||||
|
|
@ -125,7 +114,7 @@ function ParticipantRow({
|
|||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{name ?? feedLabel ?? "TBD"}
|
||||
{name ?? "TBD"}
|
||||
</span>
|
||||
|
||||
{/* Owner name below participant name */}
|
||||
|
|
@ -161,8 +150,6 @@ interface BracketMatchSlotProps {
|
|||
slotHeight: number;
|
||||
ownershipMap: Map<string, BracketOwnership>;
|
||||
userParticipantIds: Set<string>;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
}
|
||||
|
||||
export function BracketMatchSlot({
|
||||
|
|
@ -170,8 +157,6 @@ export function BracketMatchSlot({
|
|||
slotHeight,
|
||||
ownershipMap,
|
||||
userParticipantIds,
|
||||
feeders,
|
||||
template,
|
||||
}: BracketMatchSlotProps) {
|
||||
const rowHeight = slotHeight / 2;
|
||||
const showText = rowHeight >= 10;
|
||||
|
|
@ -202,13 +187,6 @@ export function BracketMatchSlot({
|
|||
|
||||
const INSET = Math.max(1, Math.min(2, Math.floor(slotHeight / 20)));
|
||||
|
||||
// An empty slot reads better as "Loser of Winners SF 2" than "TBD" — especially for
|
||||
// the feeds that cross between the winners and elimination brackets, which render as
|
||||
// separate trees and so can never be joined by a line.
|
||||
const slotSources = feeders?.get(matchKey(match.round, match.matchNumber));
|
||||
const feed1 = describeSlotSource(slotSources?.[0], template);
|
||||
const feed2 = describeSlotSource(slotSources?.[1], template);
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden" style={{ height: slotHeight }}>
|
||||
{/* Corona layer — left-2 matches card borderRadius to prevent gradient bleed at left corners */}
|
||||
|
|
@ -230,7 +208,6 @@ export function BracketMatchSlot({
|
|||
>
|
||||
<ParticipantRow
|
||||
name={match.participant1?.name ?? null}
|
||||
feedLabel={feed1}
|
||||
isTbd={isTbd1}
|
||||
isWinner={p1IsWinner}
|
||||
isLoser={p1IsLoser}
|
||||
|
|
@ -244,7 +221,6 @@ export function BracketMatchSlot({
|
|||
/>
|
||||
<ParticipantRow
|
||||
name={match.participant2?.name ?? null}
|
||||
feedLabel={feed2}
|
||||
isTbd={isTbd2}
|
||||
isWinner={p2IsWinner}
|
||||
isLoser={p2IsLoser}
|
||||
|
|
@ -261,45 +237,54 @@ export function BracketMatchSlot({
|
|||
);
|
||||
}
|
||||
|
||||
// ─── Connector column ─────────────────────────────────────────────────────────
|
||||
// ─── Per-pair connector column ────────────────────────────────────────────────
|
||||
|
||||
interface ConnectorColumnProps {
|
||||
/** Edges crossing this gutter, in slot units. */
|
||||
edges: { fromCenter: number; toCenter: number }[];
|
||||
rowHeight: number;
|
||||
offset: number;
|
||||
currentMatches: BracketMatch[];
|
||||
nextMatches: BracketMatch[];
|
||||
bracketHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the feeder edges crossing one gutter. Because the layout assigns columns by
|
||||
* depth from the final, every edge spans exactly one gutter — so a card that enters the
|
||||
* bracket late is drawn in the column where it actually plays, and there is never an
|
||||
* edge to route across a skipped column.
|
||||
*/
|
||||
function ConnectorColumn({ edges, rowHeight, offset, bracketHeight }: ConnectorColumnProps) {
|
||||
function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: ConnectorColumnProps) {
|
||||
const mid = CONNECTOR_WIDTH / 2;
|
||||
|
||||
// Merge the two edges feeding one card into a single elbow, so a pair reads as one
|
||||
// bracket join rather than two overlapping lines.
|
||||
const byTarget = new Map<number, number[]>();
|
||||
for (const { fromCenter, toCenter } of edges) {
|
||||
const sources = byTarget.get(toCenter) ?? [];
|
||||
sources.push(fromCenter);
|
||||
byTarget.set(toCenter, sources);
|
||||
}
|
||||
|
||||
const paths: string[] = [];
|
||||
for (const [toCenter, sources] of byTarget) {
|
||||
const destY = toCenter * rowHeight - offset;
|
||||
const ys = sources.map((c) => c * rowHeight - offset).toSorted((a, b) => a - b);
|
||||
if (ys.length === 1) {
|
||||
paths.push(`M 0 ${ys[0]} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`);
|
||||
continue;
|
||||
|
||||
const currentSlotH = bracketHeight / Math.max(currentMatches.length, 1);
|
||||
const nextSlotH = bracketHeight / Math.max(nextMatches.length, 1);
|
||||
|
||||
// Use halving U-shapes only when prev > 1 (avoids false-positive 1→1 side branches like 3PG→Finals)
|
||||
if (nextMatches.length === Math.ceil(currentMatches.length / 2) && currentMatches.length > 1) {
|
||||
// Standard single-elimination halving: U-shape connectors
|
||||
for (let k = 0; k < nextMatches.length; k++) {
|
||||
const topY = (2 * k) * currentSlotH + currentSlotH / 2;
|
||||
const midY = k * nextSlotH + nextSlotH / 2;
|
||||
const botIdx = 2 * k + 1;
|
||||
|
||||
if (botIdx < currentMatches.length) {
|
||||
const botY = botIdx * currentSlotH + currentSlotH / 2;
|
||||
paths.push(`M 0 ${topY} H ${mid} V ${botY} H 0`);
|
||||
paths.push(`M ${mid} ${midY} H ${CONNECTOR_WIDTH}`);
|
||||
} else {
|
||||
paths.push(`M 0 ${topY} H ${CONNECTOR_WIDTH}`);
|
||||
}
|
||||
}
|
||||
paths.push(`M 0 ${ys[0]} H ${mid} V ${ys[ys.length - 1]} H 0`);
|
||||
for (const y of ys.slice(1, -1)) paths.push(`M 0 ${y} H ${mid}`);
|
||||
paths.push(`M ${mid} ${destY} H ${CONNECTOR_WIDTH}`);
|
||||
} else {
|
||||
// Non-standard (byes, play-ins, etc.): trace winners by participantId
|
||||
const winnerToIdx = new Map<string, number>();
|
||||
currentMatches.forEach((m, idx) => {
|
||||
if (m.winnerId) winnerToIdx.set(m.winnerId, idx);
|
||||
});
|
||||
|
||||
nextMatches.forEach((nextMatch, nextIdx) => {
|
||||
const destY = nextIdx * nextSlotH + nextSlotH / 2;
|
||||
for (const pId of [nextMatch.participant1Id, nextMatch.participant2Id]) {
|
||||
if (!pId) continue;
|
||||
const srcIdx = winnerToIdx.get(pId);
|
||||
if (srcIdx === undefined) continue;
|
||||
const srcY = srcIdx * currentSlotH + currentSlotH / 2;
|
||||
paths.push(`M 0 ${srcY} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -325,131 +310,52 @@ function ConnectorColumn({ edges, rowHeight, offset, bracketHeight }: ConnectorC
|
|||
|
||||
// ─── Tree columns (shared by full + paginated) ───────────────────────────────
|
||||
|
||||
export interface BracketGeometry {
|
||||
layout: BracketLayout<BracketMatch>;
|
||||
/** Height of one leaf row. */
|
||||
rowHeight: number;
|
||||
/** Height of the card area, excluding the round labels. */
|
||||
bracketHeight: number;
|
||||
/** Narrowest the columns and gutters can be drawn without overlapping. */
|
||||
minWidth: number;
|
||||
/** Pixels trimmed off the top, non-zero only for a cropped column window. */
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out a group's matches from the feeder graph and derive its pixel geometry.
|
||||
*
|
||||
* Height comes from the number of leaf rows rather than the largest round, so a bracket
|
||||
* whose widest column isn't its first still gets the room it needs.
|
||||
*/
|
||||
export function bracketGeometry(
|
||||
visibleRounds: string[],
|
||||
matchesByRound: Map<string, BracketMatch[]>,
|
||||
feeders: FeederMap | undefined,
|
||||
templateRoundOrder: string[]
|
||||
): BracketGeometry {
|
||||
const layout = computeGroupLayout(
|
||||
visibleRounds,
|
||||
matchesByRound,
|
||||
feeders ?? new Map(),
|
||||
templateRoundOrder
|
||||
);
|
||||
const rowHeight = DESIRED_CARD_HEIGHT + CARD_GAP;
|
||||
const columnCount = Math.max(layout.columns.length, 1);
|
||||
return {
|
||||
layout,
|
||||
rowHeight,
|
||||
bracketHeight: Math.max(layout.leafCount, 1) * rowHeight,
|
||||
minWidth: columnCount * COLUMN_WIDTH + (columnCount - 1) * CONNECTOR_WIDTH,
|
||||
offset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Crop a layout to a window of columns, as the mobile pager does.
|
||||
*
|
||||
* Card positions are absolute within the whole bracket, so showing a slice of columns
|
||||
* means trimming the empty space above them rather than re-flowing — otherwise a later
|
||||
* page would render its two columns stranded at the bottom of a full-height bracket.
|
||||
*/
|
||||
export function windowGeometry(
|
||||
geometry: BracketGeometry,
|
||||
firstColumn: number,
|
||||
lastColumn: number
|
||||
): BracketGeometry {
|
||||
const centers = geometry.layout.columns
|
||||
.slice(firstColumn, lastColumn + 1)
|
||||
.flatMap((c) => c.matches.map((m) => m.center));
|
||||
if (centers.length === 0) return geometry;
|
||||
|
||||
const min = Math.min(...centers);
|
||||
const max = Math.max(...centers);
|
||||
return {
|
||||
...geometry,
|
||||
bracketHeight: (max - min + 1) * geometry.rowHeight,
|
||||
offset: (min - 0.5) * geometry.rowHeight,
|
||||
};
|
||||
}
|
||||
|
||||
interface TreeColumnsProps {
|
||||
geometry: BracketGeometry;
|
||||
visibleRounds: string[];
|
||||
matchesByRound: Map<string, BracketMatch[]>;
|
||||
ownershipMap: Map<string, BracketOwnership>;
|
||||
userParticipantIds: Set<string>;
|
||||
bracketHeight: number;
|
||||
transitionDuration?: number;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
/** Restrict rendering to a window of columns (used by the mobile pager). */
|
||||
columnRange?: [number, number];
|
||||
}
|
||||
|
||||
export function TreeColumns({
|
||||
geometry,
|
||||
visibleRounds,
|
||||
matchesByRound,
|
||||
ownershipMap,
|
||||
userParticipantIds,
|
||||
bracketHeight,
|
||||
transitionDuration,
|
||||
feeders,
|
||||
template,
|
||||
columnRange,
|
||||
}: TreeColumnsProps) {
|
||||
const tr = transitionDuration ? `${transitionDuration}ms ease` : undefined;
|
||||
const { layout, rowHeight, bracketHeight, offset } = geometry;
|
||||
|
||||
const [firstColumn, lastColumn] = columnRange ?? [0, layout.columns.length - 1];
|
||||
const visible = layout.columns.slice(firstColumn, lastColumn + 1);
|
||||
|
||||
// Cards keep a fixed height regardless of how many share a column — stretching a
|
||||
// lone final to fill its column is what made it tower over the rest of the bracket.
|
||||
const cardHeight = Math.min(
|
||||
Math.max(rowHeight - CARD_GAP, 1),
|
||||
MAX_CARD_HEIGHT
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", width: "100%", height: bracketHeight + LABEL_HEIGHT, transition: tr ? `height ${tr}` : undefined }}>
|
||||
{visible.map((column, vi) => {
|
||||
const ci = firstColumn + vi;
|
||||
const gutterEdges = layout.edges.filter((e) => e.fromColumn === ci);
|
||||
{visibleRounds.map((round, ri) => {
|
||||
const roundMatches = matchesByRound.get(round) ?? [];
|
||||
const slotHeight = bracketHeight / Math.max(roundMatches.length, 1);
|
||||
const cardHeight = Math.min(slotHeight - CARD_GAP, MAX_CARD_HEIGHT);
|
||||
const cardTop = (slotHeight - cardHeight) / 2;
|
||||
const nextRound = ri < visibleRounds.length - 1 ? visibleRounds[ri + 1] : null;
|
||||
const nextMatches = nextRound ? (matchesByRound.get(nextRound) ?? []) : [];
|
||||
|
||||
return (
|
||||
<div key={column.label + ci} style={{ display: "contents" }}>
|
||||
<div key={round} style={{ display: "contents" }}>
|
||||
{/* Round column */}
|
||||
<div style={{ flex: "1 1 0", minWidth: COLUMN_WIDTH, position: "relative" }}>
|
||||
<div
|
||||
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate text-center"
|
||||
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
>
|
||||
{column.label}
|
||||
{round}
|
||||
</div>
|
||||
|
||||
<div style={{ position: "relative", height: bracketHeight, transition: tr ? `height ${tr}` : undefined }}>
|
||||
{column.matches.map(({ match, center }) => (
|
||||
{roundMatches.map((match, matchIdx) => (
|
||||
<div
|
||||
key={match.id}
|
||||
data-match-id={match.id}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: center * rowHeight - offset - cardHeight / 2,
|
||||
top: matchIdx * slotHeight + cardTop,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: cardHeight,
|
||||
|
|
@ -461,8 +367,6 @@ export function TreeColumns({
|
|||
slotHeight={cardHeight}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -470,11 +374,10 @@ export function TreeColumns({
|
|||
</div>
|
||||
|
||||
{/* Connector between this column and the next */}
|
||||
{vi < visible.length - 1 && (
|
||||
{nextRound && (
|
||||
<ConnectorColumn
|
||||
edges={gutterEdges}
|
||||
rowHeight={rowHeight}
|
||||
offset={offset}
|
||||
currentMatches={roundMatches}
|
||||
nextMatches={nextMatches}
|
||||
bracketHeight={bracketHeight}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -493,8 +396,6 @@ interface BracketTreeViewProps {
|
|||
ownershipMap: Map<string, BracketOwnership>;
|
||||
userParticipantIds: Set<string>;
|
||||
thirdPlaceRound?: string;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
}
|
||||
|
||||
export function BracketTreeView({
|
||||
|
|
@ -503,19 +404,13 @@ export function BracketTreeView({
|
|||
ownershipMap,
|
||||
userParticipantIds,
|
||||
thirdPlaceRound,
|
||||
feeders,
|
||||
template,
|
||||
}: BracketTreeViewProps) {
|
||||
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
||||
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
||||
|
||||
const geometry = bracketGeometry(
|
||||
mainRounds,
|
||||
matchesByRound,
|
||||
feeders,
|
||||
template?.rounds.map((r) => r.name) ?? mainRounds
|
||||
);
|
||||
const { bracketHeight, minWidth } = geometry;
|
||||
const maxMatches = Math.max(...mainRounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||||
const bracketHeight = maxMatches * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
||||
const minWidth = mainRounds.length * COLUMN_WIDTH + Math.max(0, mainRounds.length - 1) * CONNECTOR_WIDTH;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -524,11 +419,11 @@ export function BracketTreeView({
|
|||
>
|
||||
<div style={{ minWidth }}>
|
||||
<TreeColumns
|
||||
geometry={geometry}
|
||||
visibleRounds={mainRounds}
|
||||
matchesByRound={matchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
bracketHeight={bracketHeight}
|
||||
/>
|
||||
{thirdPlaceMatch && (
|
||||
<div style={{ display: "flex", paddingTop: 20 }}>
|
||||
|
|
@ -546,8 +441,6 @@ export function BracketTreeView({
|
|||
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,5 @@
|
|||
import type { BracketTemplate, ConferenceGroup } from "~/lib/bracket-templates";
|
||||
import type { FeederMap } from "~/lib/bracket-layout";
|
||||
import {
|
||||
TreeColumns,
|
||||
bracketGeometry,
|
||||
type BracketMatch,
|
||||
type BracketOwnership,
|
||||
} from "./BracketTreeView";
|
||||
import type { ConferenceGroup } from "~/lib/bracket-templates";
|
||||
import { TreeColumns, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||||
|
||||
interface NbaBracketLayoutProps {
|
||||
|
|
@ -16,10 +10,11 @@ interface NbaBracketLayoutProps {
|
|||
userParticipantIds: Set<string>;
|
||||
conferenceGroups: ConferenceGroup[];
|
||||
scoringRoundIdx: number;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
}
|
||||
|
||||
const DESIRED_CARD_HEIGHT = 112;
|
||||
const CARD_GAP = 14;
|
||||
|
||||
function splitMatchesByConference(
|
||||
matchesByRound: Map<string, BracketMatch[]>,
|
||||
group: ConferenceGroup
|
||||
|
|
@ -33,6 +28,11 @@ function splitMatchesByConference(
|
|||
return result;
|
||||
}
|
||||
|
||||
function bracketHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string[]): number {
|
||||
const max = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||||
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
||||
}
|
||||
|
||||
export function NbaBracketLayout({
|
||||
rounds,
|
||||
matchesByRound,
|
||||
|
|
@ -40,10 +40,7 @@ export function NbaBracketLayout({
|
|||
userParticipantIds,
|
||||
conferenceGroups,
|
||||
scoringRoundIdx,
|
||||
feeders,
|
||||
template,
|
||||
}: NbaBracketLayoutProps) {
|
||||
const roundOrder = template?.rounds.map((r) => r.name) ?? rounds;
|
||||
// Rounds that belong to any conference group
|
||||
const conferenceRoundSet = new Set(
|
||||
conferenceGroups.flatMap((g) => Object.keys(g.roundMatchNumbers))
|
||||
|
|
@ -60,7 +57,7 @@ export function NbaBracketLayout({
|
|||
const sharedMatches = new Map(
|
||||
sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []])
|
||||
);
|
||||
const sharedGeometry = bracketGeometry(sharedRounds, sharedMatches, feeders, roundOrder);
|
||||
const sharedHeight = bracketHeight(sharedMatches, sharedRounds);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -69,7 +66,7 @@ export function NbaBracketLayout({
|
|||
{conferenceGroups.map((group, gi) => {
|
||||
const confRounds = conferenceRounds[gi];
|
||||
const confMatches = splitMatchesByConference(matchesByRound, group);
|
||||
const geometry = bracketGeometry(confRounds, confMatches, feeders, roundOrder);
|
||||
const height = bracketHeight(confMatches, confRounds);
|
||||
|
||||
return (
|
||||
<div key={group.name}>
|
||||
|
|
@ -77,11 +74,11 @@ export function NbaBracketLayout({
|
|||
{group.name}
|
||||
</p>
|
||||
<TreeColumns
|
||||
geometry={geometry}
|
||||
visibleRounds={confRounds}
|
||||
matchesByRound={confMatches}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
bracketHeight={height}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -90,11 +87,11 @@ export function NbaBracketLayout({
|
|||
{sharedRounds.length > 0 && (
|
||||
<div>
|
||||
<TreeColumns
|
||||
geometry={sharedGeometry}
|
||||
visibleRounds={sharedRounds}
|
||||
matchesByRound={sharedMatches}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
bracketHeight={sharedHeight}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -108,8 +105,6 @@ export function NbaBracketLayout({
|
|||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
firstScoringRoundIdx={scoringRoundIdx}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -13,8 +13,7 @@ import { GradientIcon } from "~/components/ui/GradientIcon";
|
|||
import { RankingsRow } from "./RankingsRow";
|
||||
import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||||
import { getBracketTemplate, type BracketTemplate } from "~/lib/bracket-templates";
|
||||
import { buildFeederMap } from "~/lib/bracket-layout";
|
||||
import { getBracketTemplate } from "~/lib/bracket-templates";
|
||||
import { NbaBracketLayout } from "./NbaBracketLayout";
|
||||
import { TabbedBracketLayout } from "./TabbedBracketLayout";
|
||||
|
||||
|
|
@ -77,6 +76,43 @@ export function groupMatchesByRound(matches: Match[]): Map<string, Match[]> {
|
|||
return byRound;
|
||||
}
|
||||
|
||||
/**
|
||||
* For a standard single-elimination bracket, slot p1 of match N in round R
|
||||
* comes from match (2N-1) in the previous round, and slot p2 from match 2N.
|
||||
*/
|
||||
export function buildFeederMap(
|
||||
matchesByRound: Map<string, Match[]>,
|
||||
orderedRounds: string[]
|
||||
): Map<string, { round: string; matchNumber: number }> {
|
||||
const feederMap = new Map<string, { round: string; matchNumber: number }>();
|
||||
|
||||
for (let ri = 1; ri < orderedRounds.length; ri++) {
|
||||
const currentRound = orderedRounds[ri];
|
||||
const prevRound = orderedRounds[ri - 1];
|
||||
const prevMatchNums = new Set(
|
||||
(matchesByRound.get(prevRound) || []).map((m) => m.matchNumber)
|
||||
);
|
||||
for (const match of matchesByRound.get(currentRound) || []) {
|
||||
const p1Src = 2 * (match.matchNumber - 1) + 1;
|
||||
const p2Src = 2 * (match.matchNumber - 1) + 2;
|
||||
if (prevMatchNums.has(p1Src)) {
|
||||
feederMap.set(`${currentRound}:${match.matchNumber}:p1`, {
|
||||
round: prevRound,
|
||||
matchNumber: p1Src,
|
||||
});
|
||||
}
|
||||
if (prevMatchNums.has(p2Src)) {
|
||||
feederMap.set(`${currentRound}:${match.matchNumber}:p2`, {
|
||||
round: prevRound,
|
||||
matchNumber: p2Src,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return feederMap;
|
||||
}
|
||||
|
||||
interface EliminatedEntry {
|
||||
participant: Participant;
|
||||
score: string | null;
|
||||
|
|
@ -138,182 +174,6 @@ export function computeEliminatedByRound(
|
|||
return result;
|
||||
}
|
||||
|
||||
/** The score recorded for one participant in a match, or null if they didn't play in it. */
|
||||
function participantScore(match: Match, participantId: string | null): string | null {
|
||||
if (!participantId) return null;
|
||||
if (participantId === match.participant1Id) return match.participant1Score;
|
||||
if (participantId === match.participant2Id) return match.participant2Score;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A consolation final: a round contested by the losers of an earlier round, which
|
||||
* splits the positions those losers would otherwise share. FIFA's "Third Place Game"
|
||||
* (fed by the Semifinals) is the only one in the templates today.
|
||||
*/
|
||||
export interface ConsolationRound {
|
||||
/** The consolation round itself, e.g. "Third Place Game". */
|
||||
round: string;
|
||||
/** The round whose losers contest it, e.g. "Semifinals". */
|
||||
feederRound: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the template's consolation round, if it has one.
|
||||
*
|
||||
* A consolation round must be TERMINAL — its winner plays no further game, which is
|
||||
* what lets its result split two exact positions. `loserFeedsInto` alone is not
|
||||
* enough: a double-elimination bracket (llws_20) uses it on every winners-bracket
|
||||
* round to route losers into the elimination bracket, and those targets are ordinary
|
||||
* rounds that feed onward. Picking the first `loserFeedsInto` there would mistake
|
||||
* "Elimination Round 1" for a third-place game and corrupt the final rankings.
|
||||
*
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function findConsolationRound(
|
||||
template: BracketTemplate | undefined
|
||||
): ConsolationRound | undefined {
|
||||
const isTerminal = (roundName: string) =>
|
||||
template?.rounds.find((r) => r.name === roundName)?.feedsInto === null;
|
||||
|
||||
const feeder = template?.rounds.find(
|
||||
(r) => r.loserFeedsInto && isTerminal(r.loserFeedsInto)
|
||||
);
|
||||
if (!feeder?.loserFeedsInto) return undefined;
|
||||
return { round: feeder.loserFeedsInto, feederRound: feeder.name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Round names whose losers are placed by some LATER round rather than finishing where
|
||||
* they lost — i.e. double-elimination winners-bracket rounds, whose losers drop into
|
||||
* the elimination bracket.
|
||||
*
|
||||
* The consolation feeder is deliberately excluded: its losers do finish at that tier
|
||||
* (the consolation game splits their two positions), so it still consumes them.
|
||||
*
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function roundsWithLosersPlacedLater(
|
||||
template: BracketTemplate | undefined,
|
||||
consolation: ConsolationRound | undefined
|
||||
): Set<string> {
|
||||
return new Set(
|
||||
(template?.rounds ?? [])
|
||||
.filter((r) => r.loserFeedsInto && r.name !== consolation?.feederRound)
|
||||
.map((r) => r.name)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ordered final-rankings list from completed matches.
|
||||
*
|
||||
* Ranks are derived by walking rounds latest-first: the final's loser is 2nd, the
|
||||
* previous round's losers share the next tier, and so on — each round consuming as
|
||||
* many positions as it has matches.
|
||||
*
|
||||
* A consolation round needs different handling, because its winner never loses a
|
||||
* match and so the loser-driven walk above would leave them unranked and "in
|
||||
* contention" forever. Its two places are exactly the top of the tier its feeder
|
||||
* round's losers would otherwise share, so it is resolved *at the feeder round* —
|
||||
* the winner takes that tier's first position and the loser the second — and the
|
||||
* consolation round itself consumes no positions. Positions are derived rather than
|
||||
* hardcoded, so a consolation round hanging off a different feeder still lands right.
|
||||
*
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function computeRankedEntries(
|
||||
matches: Match[],
|
||||
rounds: string[],
|
||||
matchesByRound: Map<string, Match[]>,
|
||||
consolation: ConsolationRound | undefined,
|
||||
ownershipMap: Map<string, TeamOwnership>,
|
||||
/** See roundsWithLosersPlacedLater. Empty for single-elimination brackets. */
|
||||
losersPlacedLater: Set<string> = new Set()
|
||||
): EliminatedEntry[] {
|
||||
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
|
||||
|
||||
// Only take the consolation path when both rounds actually have matches; otherwise
|
||||
// fall through to the loser-driven walk so nothing is dropped.
|
||||
const consolationActive =
|
||||
consolation &&
|
||||
rounds.includes(consolation.round) &&
|
||||
rounds.includes(consolation.feederRound);
|
||||
|
||||
// Consolation matches we can place exactly. Anything else in that round (still in
|
||||
// progress, or missing its hydrated winner/loser) deliberately stays eligible for
|
||||
// the loser-driven walk rather than being silently dropped.
|
||||
const consolationMatches =
|
||||
consolationActive && consolation
|
||||
? (matchesByRound.get(consolation.round) ?? []).filter(
|
||||
(m): m is Match & { winner: Participant; loser: Participant } =>
|
||||
m.isComplete && !!m.winner && !!m.loser
|
||||
)
|
||||
: [];
|
||||
const exactlyPlacedMatchIds = new Set(consolationMatches.map((m) => m.id));
|
||||
|
||||
const entryFor = (
|
||||
match: Match,
|
||||
participant: Participant,
|
||||
participantId: string | null
|
||||
): Omit<EliminatedEntry, "rankLabel"> => ({
|
||||
participant,
|
||||
score: participantScore(match, participantId),
|
||||
ownership: ownershipMap.get(participant.id) || null,
|
||||
});
|
||||
|
||||
const losersByRound = new Map<string, Omit<EliminatedEntry, "rankLabel">[]>();
|
||||
for (const match of matches) {
|
||||
if (!match.isComplete || !match.loser) continue;
|
||||
if (exactlyPlacedMatchIds.has(match.id)) continue;
|
||||
if (!eliminatedByRound.get(match.round)?.includes(match.loser.id)) continue;
|
||||
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
|
||||
losersByRound.get(match.round)?.push(entryFor(match, match.loser, match.loserId));
|
||||
}
|
||||
|
||||
const rankedEntries: EliminatedEntry[] = [];
|
||||
let nextRank = 2;
|
||||
for (let ri = rounds.length - 1; ri >= 0; ri--) {
|
||||
const roundName = rounds[ri];
|
||||
|
||||
// The consolation match splits the top of its feeder round's tier, so it is
|
||||
// placed first and the round's remaining losers share what's left below it.
|
||||
let tierRank = nextRank;
|
||||
if (consolationActive && roundName === consolation?.feederRound) {
|
||||
for (const match of consolationMatches) {
|
||||
rankedEntries.push({
|
||||
...entryFor(match, match.winner, match.winnerId),
|
||||
rankLabel: `${tierRank}`,
|
||||
});
|
||||
rankedEntries.push({
|
||||
...entryFor(match, match.loser, match.loserId),
|
||||
rankLabel: `${tierRank + 1}`,
|
||||
});
|
||||
tierRank += 2;
|
||||
}
|
||||
}
|
||||
|
||||
for (const loser of losersByRound.get(roundName) ?? []) {
|
||||
rankedEntries.push({ ...loser, rankLabel: `T${tierRank}` });
|
||||
}
|
||||
|
||||
// The consolation round's places belong to its feeder round's tier, so it
|
||||
// consumes none of its own.
|
||||
if (consolationActive && roundName === consolation?.round) continue;
|
||||
|
||||
// A round normally consumes one position per match — its losers finish here,
|
||||
// whether or not the games have been played yet (four semifinalists occupy 1–4
|
||||
// regardless). But in a double-elimination bracket a winners-bracket loss places
|
||||
// nobody: the loser drops into the elimination bracket and is ranked by whatever
|
||||
// knocks them out later. Those rounds must consume nothing, or every position
|
||||
// below inflates (a 20-team llws_20 bracket would end at "T23").
|
||||
if (losersPlacedLater.has(roundName)) continue;
|
||||
|
||||
nextRank += matchesByRound.get(roundName)?.length ?? 0;
|
||||
}
|
||||
|
||||
return rankedEntries;
|
||||
}
|
||||
|
||||
/** Find the index of the first round that has scoring matches. */
|
||||
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number {
|
||||
for (let i = 0; i < rounds.length; i++) {
|
||||
|
|
@ -355,13 +215,13 @@ export function PlayoffBracket({
|
|||
const matchesByRound = groupMatchesByRound(matches);
|
||||
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
|
||||
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
|
||||
// What fills each slot, used for both card placement and naming empty slots.
|
||||
const feeders = buildFeederMap(template);
|
||||
|
||||
const consolation = findConsolationRound(template);
|
||||
const thirdPlaceRound = consolation?.round;
|
||||
const thirdPlaceRound = template?.rounds
|
||||
.find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name))
|
||||
?.name;
|
||||
|
||||
// Build elimination rankings
|
||||
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
|
||||
let bracketWinner: Participant | null = null;
|
||||
|
||||
const lastRound = rounds[rounds.length - 1];
|
||||
|
|
@ -370,20 +230,43 @@ export function PlayoffBracket({
|
|||
: null;
|
||||
if (finalMatch?.winner) bracketWinner = finalMatch.winner;
|
||||
|
||||
const eliminatedByRound = computeEliminatedByRound(matches, rounds);
|
||||
|
||||
for (const match of matches) {
|
||||
if (!match.isComplete || !match.loser) continue;
|
||||
const eliminatedInRound = eliminatedByRound.get(match.round);
|
||||
if (!eliminatedInRound?.includes(match.loser.id)) continue;
|
||||
const loserScore =
|
||||
match.loserId === match.participant1Id
|
||||
? match.participant1Score
|
||||
: match.participant2Score;
|
||||
if (!losersByRound.has(match.round)) losersByRound.set(match.round, []);
|
||||
losersByRound.get(match.round)?.push({
|
||||
participant: match.loser,
|
||||
score: loserScore,
|
||||
ownership: ownershipMap.get(match.loser.id) || null,
|
||||
});
|
||||
}
|
||||
|
||||
const allBracketParticipantIds = new Set<string>();
|
||||
for (const match of matches) {
|
||||
if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id);
|
||||
if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id);
|
||||
}
|
||||
|
||||
const rankedEntries = computeRankedEntries(
|
||||
matches,
|
||||
rounds,
|
||||
matchesByRound,
|
||||
consolation,
|
||||
ownershipMap,
|
||||
roundsWithLosersPlacedLater(template, consolation)
|
||||
);
|
||||
const rankedEntries: EliminatedEntry[] = [];
|
||||
let nextRank = 2;
|
||||
for (let ri = rounds.length - 1; ri >= 0; ri--) {
|
||||
const roundName = rounds[ri];
|
||||
const roundLosers = losersByRound.get(roundName) || [];
|
||||
const totalMatchesInRound = matchesByRound.get(roundName)?.length ?? 0;
|
||||
if (roundLosers.length > 0) {
|
||||
const rankLabel = `T${nextRank}`;
|
||||
for (const loser of roundLosers) {
|
||||
rankedEntries.push({ ...loser, rankLabel });
|
||||
}
|
||||
}
|
||||
nextRank += totalMatchesInRound;
|
||||
}
|
||||
|
||||
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
|
||||
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);
|
||||
|
|
@ -401,14 +284,7 @@ export function PlayoffBracket({
|
|||
const activeParticipants = [...allBracketParticipantIds]
|
||||
.filter((id) => !rankedParticipantIds.has(id))
|
||||
.map((id) => participantMap.get(id))
|
||||
.filter((p): p is Participant => p !== undefined)
|
||||
// Owned-by-a-manager players first, then alphabetical by name.
|
||||
.toSorted((a, b) => {
|
||||
const aOwned = ownershipMap.has(a.id);
|
||||
const bOwned = ownershipMap.has(b.id);
|
||||
if (aOwned !== bOwned) return aOwned ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
.filter((p): p is Participant => p !== undefined);
|
||||
|
||||
const isDraftedOrScoring = (participantId: string) =>
|
||||
ownershipMap.has(participantId) || (pointsMap.get(participantId) ?? 0) > 0;
|
||||
|
|
@ -444,8 +320,6 @@ export function PlayoffBracket({
|
|||
userParticipantIds={userParticipantSet}
|
||||
phases={template.phases}
|
||||
scoringRoundIdx={scoringRoundIdx}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
) : template?.conferenceGroups ? (
|
||||
<NbaBracketLayout
|
||||
|
|
@ -456,8 +330,6 @@ export function PlayoffBracket({
|
|||
userParticipantIds={userParticipantSet}
|
||||
conferenceGroups={template.conferenceGroups}
|
||||
scoringRoundIdx={scoringRoundIdx}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -469,8 +341,6 @@ export function PlayoffBracket({
|
|||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||||
userParticipantIds={userParticipantSet}
|
||||
thirdPlaceRound={thirdPlaceRound}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -483,8 +353,6 @@ export function PlayoffBracket({
|
|||
userParticipantIds={userParticipantSet}
|
||||
firstScoringRoundIdx={scoringRoundIdx}
|
||||
thirdPlaceRound={thirdPlaceRound}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -55,9 +55,7 @@ interface QualifyingPointsStandingsProps {
|
|||
function formatQP(raw: string): string {
|
||||
const n = parseFloat(raw);
|
||||
if (isNaN(n)) return "—";
|
||||
// At most 2 decimals, trailing zeros trimmed (1.50→1.5). Mirrors the Discord
|
||||
// embed's formatQPValue (app/services/discord.ts) so the site and Discord agree.
|
||||
return parseFloat(n.toFixed(2)).toString();
|
||||
return n % 1 === 0 ? n.toString() : n.toFixed(2);
|
||||
}
|
||||
|
||||
export function QualifyingPointsStandings({
|
||||
|
|
|
|||
|
|
@ -1,18 +1,8 @@
|
|||
import { cn } from "~/lib/utils";
|
||||
import type { BracketPhase, BracketTemplate, ConferenceGroup } from "~/lib/bracket-templates";
|
||||
import type { FeederMap } from "~/lib/bracket-layout";
|
||||
import {
|
||||
TreeColumns,
|
||||
BracketMatchSlot,
|
||||
bracketGeometry,
|
||||
type BracketMatch,
|
||||
type BracketOwnership,
|
||||
} from "./BracketTreeView";
|
||||
import type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates";
|
||||
import { TreeColumns, BracketMatchSlot, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||||
|
||||
/** Card height for the play-in columns, which lay themselves out rather than via TreeColumns. */
|
||||
const CARD_H = 112;
|
||||
|
||||
interface TabbedBracketLayoutProps {
|
||||
rounds: string[];
|
||||
matchesByRound: Map<string, BracketMatch[]>;
|
||||
|
|
@ -20,10 +10,11 @@ interface TabbedBracketLayoutProps {
|
|||
userParticipantIds: Set<string>;
|
||||
phases: BracketPhase[];
|
||||
scoringRoundIdx: number;
|
||||
feeders?: FeederMap;
|
||||
template?: BracketTemplate;
|
||||
}
|
||||
|
||||
const CARD_H = 112;
|
||||
const CARD_GAP = 14;
|
||||
|
||||
function groupMatches(
|
||||
matchesByRound: Map<string, BracketMatch[]>,
|
||||
group: ConferenceGroup
|
||||
|
|
@ -38,6 +29,11 @@ function groupMatches(
|
|||
return out;
|
||||
}
|
||||
|
||||
function phaseHeight(matchesByRound: Map<string, BracketMatch[]>, rounds: string[]): number {
|
||||
const max = Math.max(...rounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||||
return max * (CARD_H + CARD_GAP);
|
||||
}
|
||||
|
||||
// ─── Play-In Layout ───────────────────────────────────────────────────────────
|
||||
|
||||
interface PlayInColumnProps {
|
||||
|
|
@ -145,10 +141,7 @@ export function TabbedBracketLayout({
|
|||
userParticipantIds,
|
||||
phases,
|
||||
scoringRoundIdx,
|
||||
feeders,
|
||||
template,
|
||||
}: TabbedBracketLayoutProps) {
|
||||
const roundOrder = template?.rounds.map((r) => r.name) ?? rounds;
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{phases.map((phase) => {
|
||||
|
|
@ -159,22 +152,7 @@ export function TabbedBracketLayout({
|
|||
const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r));
|
||||
|
||||
const phaseRounds = phase.groups ? [...groupRounds, ...sharedRounds] : simpleRounds;
|
||||
// Restrict each round to the match numbers this phase's groups actually claim.
|
||||
// Rounds can be shared across phases (LLWS runs U.S. and International through
|
||||
// the same rounds), so without this the mobile view would merge both sides into
|
||||
// one column. No-op where a phase's groups already cover every match in the
|
||||
// round (NCAA regions, NBA conferences) and for sharedRounds, which have no
|
||||
// group filter.
|
||||
const phaseMatchesByRound = new Map(
|
||||
phaseRounds.map((r) => {
|
||||
const all = matchesByRound.get(r) ?? [];
|
||||
if (!phase.groups || sharedRounds.includes(r)) return [r, all] as const;
|
||||
const allowed = new Set(
|
||||
phase.groups.flatMap((g) => g.roundMatchNumbers[r] ?? [])
|
||||
);
|
||||
return [r, allowed.size > 0 ? all.filter((m) => allowed.has(m.matchNumber)) : all] as const;
|
||||
})
|
||||
);
|
||||
const phaseMatchesByRound = new Map(phaseRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
|
||||
const sharedMatchesByRound = new Map(sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
|
||||
|
||||
const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx);
|
||||
|
|
@ -201,87 +179,50 @@ export function TabbedBracketLayout({
|
|||
{phase.groups.map((group) => {
|
||||
const gMatches = groupMatches(matchesByRound, group);
|
||||
const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined);
|
||||
const geometry = bracketGeometry(gRounds, gMatches, feeders, roundOrder);
|
||||
return (
|
||||
<div key={group.name}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
||||
{group.name}
|
||||
</p>
|
||||
<div className="w-full overflow-x-auto">
|
||||
<div style={{ minWidth: geometry.minWidth }}>
|
||||
<TreeColumns
|
||||
geometry={geometry}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TreeColumns
|
||||
visibleRounds={gRounds}
|
||||
matchesByRound={gMatches}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
bracketHeight={phaseHeight(gMatches, gRounds)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sharedRounds.length > 0 && (
|
||||
<TreeColumns
|
||||
geometry={bracketGeometry(sharedRounds, sharedMatchesByRound, feeders, roundOrder)}
|
||||
visibleRounds={sharedRounds}
|
||||
matchesByRound={sharedMatchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
bracketHeight={phaseHeight(sharedMatchesByRound, sharedRounds)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<TreeColumns
|
||||
geometry={bracketGeometry(phaseRounds, phaseMatchesByRound, feeders, roundOrder)}
|
||||
visibleRounds={phaseRounds}
|
||||
matchesByRound={phaseMatchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
bracketHeight={phaseHeight(phaseMatchesByRound, phaseRounds)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile — paged one group at a time, matching the desktop split. Paging a
|
||||
whole phase would merge the winners and elimination brackets into one
|
||||
tree, and a double-elimination phase is a DAG rather than a tree: the
|
||||
same game feeds forward and sideways, so its column placement would be
|
||||
arbitrary. */}
|
||||
<div className="md:hidden space-y-6">
|
||||
{/* Mobile */}
|
||||
<div className="md:hidden">
|
||||
{phase.layout === "play-in" ? (
|
||||
<PlayInLayout
|
||||
matchesByRound={phaseMatchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
/>
|
||||
) : phase.groups ? (
|
||||
<>
|
||||
{phase.groups.map((group) => (
|
||||
<div key={group.name}>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
||||
{group.name}
|
||||
</p>
|
||||
<BracketTreePaginated
|
||||
rounds={groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined)}
|
||||
matchesByRound={groupMatches(matchesByRound, group)}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{sharedRounds.length > 0 && (
|
||||
<BracketTreePaginated
|
||||
rounds={sharedRounds}
|
||||
matchesByRound={sharedMatchesByRound}
|
||||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<BracketTreePaginated
|
||||
rounds={phaseRounds}
|
||||
|
|
@ -289,8 +230,6 @@ export function TabbedBracketLayout({
|
|||
ownershipMap={ownershipMap}
|
||||
userParticipantIds={userParticipantIds}
|
||||
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
|
||||
feeders={feeders}
|
||||
template={template}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,116 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { render, within } from "@testing-library/react";
|
||||
import { NbaBracketLayout } from "../NbaBracketLayout";
|
||||
import { buildFeederMap } from "~/lib/bracket-layout";
|
||||
import type { BracketTemplate } from "~/lib/bracket-templates";
|
||||
import type { BracketMatch } from "../BracketTreeView";
|
||||
|
||||
/**
|
||||
* NbaBracketLayout renders a desktop view and a mobile pager side by side, hidden from
|
||||
* each other by Tailwind breakpoints. Both need `feeders` and `template` — without them
|
||||
* bracketGeometry falls back to index-derived positions and an unplayed slot reads "TBD"
|
||||
* where the feeder graph would name the game it is waiting on.
|
||||
*/
|
||||
|
||||
const TEMPLATE: BracketTemplate = {
|
||||
id: "test_conf_4",
|
||||
name: "Two-conference test bracket",
|
||||
totalTeams: 4,
|
||||
scoringStartsAtRound: "Final",
|
||||
rounds: [
|
||||
{ name: "Semis", matchCount: 2, feedsInto: "Final", isScoring: false },
|
||||
{ name: "Final", matchCount: 1, feedsInto: null, isScoring: true },
|
||||
],
|
||||
conferenceGroups: [
|
||||
{ name: "East", roundMatchNumbers: { Semis: [1] } },
|
||||
{ name: "West", roundMatchNumbers: { Semis: [2] } },
|
||||
],
|
||||
};
|
||||
|
||||
const ROUNDS = ["Semis", "Final"];
|
||||
|
||||
function match(
|
||||
round: string,
|
||||
matchNumber: number,
|
||||
overrides: Partial<BracketMatch> = {}
|
||||
): BracketMatch {
|
||||
return {
|
||||
id: `${round}-${matchNumber}`,
|
||||
round,
|
||||
matchNumber,
|
||||
participant1Id: null,
|
||||
participant2Id: null,
|
||||
winnerId: null,
|
||||
loserId: null,
|
||||
isComplete: false,
|
||||
participant1Score: null,
|
||||
participant2Score: null,
|
||||
...overrides,
|
||||
} as BracketMatch;
|
||||
}
|
||||
|
||||
/** Semis are played; the Final's two slots are still empty. */
|
||||
const MATCHES_BY_ROUND = new Map<string, BracketMatch[]>([
|
||||
[
|
||||
"Semis",
|
||||
[
|
||||
match("Semis", 1, { participant1Id: "p1", participant2Id: "p2" }),
|
||||
match("Semis", 2, { participant1Id: "p3", participant2Id: "p4" }),
|
||||
],
|
||||
],
|
||||
["Final", [match("Final", 1)]],
|
||||
]);
|
||||
|
||||
function renderLayout(withGraph: boolean) {
|
||||
const { container } = render(
|
||||
<NbaBracketLayout
|
||||
matches={[...MATCHES_BY_ROUND.values()].flat()}
|
||||
rounds={ROUNDS}
|
||||
matchesByRound={MATCHES_BY_ROUND}
|
||||
ownershipMap={new Map()}
|
||||
userParticipantIds={new Set()}
|
||||
conferenceGroups={TEMPLATE.conferenceGroups ?? []}
|
||||
scoringRoundIdx={1}
|
||||
feeders={withGraph ? buildFeederMap(TEMPLATE) : undefined}
|
||||
template={withGraph ? TEMPLATE : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
// Both panes render in jsdom — media queries are class-based, not applied — so scope
|
||||
// each assertion to the pane it is about.
|
||||
const mobile = container.querySelector<HTMLElement>(".md\\:hidden");
|
||||
const desktop = container.querySelector<HTMLElement>(".md\\:flex");
|
||||
if (!mobile || !desktop) throw new Error("Expected both a mobile and a desktop pane");
|
||||
return { mobile, desktop };
|
||||
}
|
||||
|
||||
describe("NbaBracketLayout", () => {
|
||||
it("names the feeding game in the mobile pager", () => {
|
||||
// Only slots filled by advancement get a label; a directly seeded slot with no
|
||||
// participant still reads "TBD", which is why this asserts on the Final's slots.
|
||||
const { mobile } = renderLayout(true);
|
||||
|
||||
expect(within(mobile).getAllByText(/Winner of/).length).toBe(2);
|
||||
});
|
||||
|
||||
it("shows the mobile pager the same slot labels as the desktop view", () => {
|
||||
const { mobile, desktop } = renderLayout(true);
|
||||
|
||||
const labels = (pane: HTMLElement) =>
|
||||
within(pane)
|
||||
.getAllByText(/Winner of/)
|
||||
.map((el) => el.textContent)
|
||||
.toSorted();
|
||||
|
||||
expect(labels(mobile)).toEqual(labels(desktop));
|
||||
});
|
||||
|
||||
it("falls back to TBD when the feeder graph is unavailable", () => {
|
||||
// Guards the assertions above: without feeders/template there is nothing to name a
|
||||
// slot with, which is exactly the state the mobile pane was stuck in.
|
||||
const { mobile } = renderLayout(false);
|
||||
|
||||
expect(within(mobile).queryByText(/Winner of/)).toBeNull();
|
||||
expect(within(mobile).getAllByText("TBD").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,16 +1,5 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import {
|
||||
PlayoffBracket,
|
||||
groupMatchesByRound,
|
||||
computeEliminatedByRound,
|
||||
computeRankedEntries,
|
||||
findConsolationRound,
|
||||
roundsWithLosersPlacedLater,
|
||||
type Match,
|
||||
} from "../PlayoffBracket";
|
||||
import { getBracketTemplate } from "~/lib/bracket-templates";
|
||||
import { resolveLLWSAdvancement } from "~/models/playoff-match";
|
||||
import { buildFeederMap, groupMatchesByRound, computeEliminatedByRound } from "../PlayoffBracket";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
|
|
@ -63,72 +52,88 @@ describe("groupMatchesByRound", () => {
|
|||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendered LLWS bracket — geometry and empty-slot labels
|
||||
// buildFeederMap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("PlayoffBracket — rendered LLWS bracket", () => {
|
||||
const LLWS_ROUNDS = (getBracketTemplate("llws_20")?.rounds ?? []).map((r) => r.name);
|
||||
|
||||
/** Every LLWS match, all unplayed, so each slot shows what will fill it. */
|
||||
function emptyLlwsMatches(): Match[] {
|
||||
const template = getBracketTemplate("llws_20");
|
||||
const matches: Match[] = [];
|
||||
for (const round of template?.rounds ?? []) {
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
matches.push({
|
||||
...makeMatch(round.name, n, { participant1Id: null, participant2Id: null }),
|
||||
participant1: null,
|
||||
participant2: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
it("names empty slots after the game that feeds them", () => {
|
||||
render(
|
||||
<PlayoffBracket
|
||||
matches={emptyLlwsMatches()}
|
||||
rounds={LLWS_ROUNDS}
|
||||
bracketTemplateId="llws_20"
|
||||
/>
|
||||
);
|
||||
|
||||
// A winners-bracket loss drops into the elimination bracket — an edge that spans
|
||||
// two separately rendered trees, so the label is the only way to show it.
|
||||
expect(screen.getAllByText("Loser of Winners SF 1").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("Winner of Opening 1").length).toBeGreaterThan(0);
|
||||
describe("buildFeederMap", () => {
|
||||
it("returns an empty map when there is only one round", () => {
|
||||
const matches = [makeMatch("Finals", 1)];
|
||||
const map = buildFeederMap(groupMatchesByRound(matches), ["Finals"]);
|
||||
expect(map.size).toBe(0);
|
||||
});
|
||||
|
||||
it("still shows TBD for a directly seeded slot", () => {
|
||||
render(
|
||||
<PlayoffBracket
|
||||
matches={emptyLlwsMatches()}
|
||||
rounds={LLWS_ROUNDS}
|
||||
bracketTemplateId="llws_20"
|
||||
/>
|
||||
);
|
||||
it("maps SF slots to the correct QF matches for an 8-team bracket", () => {
|
||||
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
|
||||
const matches = [
|
||||
makeMatch("Quarterfinals", 1),
|
||||
makeMatch("Quarterfinals", 2),
|
||||
makeMatch("Quarterfinals", 3),
|
||||
makeMatch("Quarterfinals", 4),
|
||||
makeMatch("Semifinals", 1),
|
||||
makeMatch("Semifinals", 2),
|
||||
makeMatch("Finals", 1),
|
||||
];
|
||||
|
||||
// The opening round is seeded, not fed, so it has nothing to name.
|
||||
expect(screen.getAllByText("TBD").length).toBeGreaterThan(0);
|
||||
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
|
||||
|
||||
// SF Match 1, slot p1 ← QF Match 1
|
||||
expect(map.get("Semifinals:1:p1")).toEqual({ round: "Quarterfinals", matchNumber: 1 });
|
||||
// SF Match 1, slot p2 ← QF Match 2
|
||||
expect(map.get("Semifinals:1:p2")).toEqual({ round: "Quarterfinals", matchNumber: 2 });
|
||||
// SF Match 2, slot p1 ← QF Match 3
|
||||
expect(map.get("Semifinals:2:p1")).toEqual({ round: "Quarterfinals", matchNumber: 3 });
|
||||
// SF Match 2, slot p2 ← QF Match 4
|
||||
expect(map.get("Semifinals:2:p2")).toEqual({ round: "Quarterfinals", matchNumber: 4 });
|
||||
});
|
||||
|
||||
it("gives every card the same height, including a lone final", () => {
|
||||
const { container } = render(
|
||||
<PlayoffBracket
|
||||
matches={emptyLlwsMatches()}
|
||||
rounds={LLWS_ROUNDS}
|
||||
bracketTemplateId="llws_20"
|
||||
/>
|
||||
);
|
||||
it("maps Finals slots to the correct SF matches", () => {
|
||||
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
|
||||
const matches = [
|
||||
makeMatch("Quarterfinals", 1),
|
||||
makeMatch("Quarterfinals", 2),
|
||||
makeMatch("Quarterfinals", 3),
|
||||
makeMatch("Quarterfinals", 4),
|
||||
makeMatch("Semifinals", 1),
|
||||
makeMatch("Semifinals", 2),
|
||||
makeMatch("Finals", 1),
|
||||
];
|
||||
|
||||
const heights = new Set(
|
||||
[...container.querySelectorAll<HTMLElement>("[data-match-id]")].map(
|
||||
(el) => el.style.height
|
||||
)
|
||||
);
|
||||
// Previously a one-match column stretched its card to fill the bracket height.
|
||||
expect(heights.size).toBe(1);
|
||||
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
|
||||
|
||||
expect(map.get("Finals:1:p1")).toEqual({ round: "Semifinals", matchNumber: 1 });
|
||||
expect(map.get("Finals:1:p2")).toEqual({ round: "Semifinals", matchNumber: 2 });
|
||||
});
|
||||
|
||||
it("does not add an entry when the source match does not exist in the previous round", () => {
|
||||
const rounds = ["Quarterfinals", "Finals"];
|
||||
const matches = [
|
||||
makeMatch("Quarterfinals", 1),
|
||||
makeMatch("Quarterfinals", 2),
|
||||
makeMatch("Finals", 1),
|
||||
];
|
||||
|
||||
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
|
||||
|
||||
expect(map.get("Finals:1:p1")).toEqual({ round: "Quarterfinals", matchNumber: 1 });
|
||||
expect(map.get("Finals:1:p2")).toEqual({ round: "Quarterfinals", matchNumber: 2 });
|
||||
expect(map.has("Finals:2:p1")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles a 16-team bracket correctly for Round of 16 → Quarterfinals", () => {
|
||||
const rounds = ["Round of 16", "Quarterfinals", "Semifinals", "Finals"];
|
||||
const matches = [
|
||||
...[1, 2, 3, 4, 5, 6, 7, 8].map((n) => makeMatch("Round of 16", n)),
|
||||
...[1, 2, 3, 4].map((n) => makeMatch("Quarterfinals", n)),
|
||||
...[1, 2].map((n) => makeMatch("Semifinals", n)),
|
||||
makeMatch("Finals", 1),
|
||||
];
|
||||
|
||||
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
|
||||
|
||||
expect(map.get("Quarterfinals:1:p1")).toEqual({ round: "Round of 16", matchNumber: 1 });
|
||||
expect(map.get("Quarterfinals:1:p2")).toEqual({ round: "Round of 16", matchNumber: 2 });
|
||||
expect(map.get("Quarterfinals:4:p1")).toEqual({ round: "Round of 16", matchNumber: 7 });
|
||||
expect(map.get("Quarterfinals:4:p2")).toEqual({ round: "Round of 16", matchNumber: 8 });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -286,526 +291,3 @@ describe("computeEliminatedByRound", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// computeRankedEntries — consolation ("third place") round handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// fifa_48 round order. The Third Place Game sits between the Semifinals (whose
|
||||
// losers feed it) and the Finals.
|
||||
const FIFA_ROUNDS = ["Quarterfinals", "Semifinals", "Third Place Game", "Finals"];
|
||||
|
||||
const FIFA_CONSOLATION = {
|
||||
round: "Third Place Game",
|
||||
feederRound: "Semifinals",
|
||||
};
|
||||
|
||||
type MatchOpts = {
|
||||
/** Which slot the winner occupies. Defaults to 1. */
|
||||
winnerSlot?: 1 | 2;
|
||||
winnerScore?: string;
|
||||
loserScore?: string;
|
||||
/**
|
||||
* Drop the hydrated `winner` relation, keeping `loser` and both ids — the shape a
|
||||
* hand-built match object can arrive in. The loser is still placeable this way.
|
||||
*/
|
||||
missingWinnerRelation?: boolean;
|
||||
};
|
||||
|
||||
/** A completed match. */
|
||||
function makeRankedMatch(
|
||||
round: string,
|
||||
matchNumber: number,
|
||||
winnerId: string,
|
||||
loserId: string,
|
||||
opts: MatchOpts = {}
|
||||
): Match {
|
||||
const winnerIsP1 = (opts.winnerSlot ?? 1) === 1;
|
||||
const p1 = winnerIsP1 ? winnerId : loserId;
|
||||
const p2 = winnerIsP1 ? loserId : winnerId;
|
||||
return {
|
||||
id: `${round}-${matchNumber}`,
|
||||
round,
|
||||
matchNumber,
|
||||
participant1Id: p1,
|
||||
participant2Id: p2,
|
||||
winnerId,
|
||||
loserId,
|
||||
isComplete: true,
|
||||
participant1Score: (winnerIsP1 ? opts.winnerScore : opts.loserScore) ?? null,
|
||||
participant2Score: (winnerIsP1 ? opts.loserScore : opts.winnerScore) ?? null,
|
||||
participant1: { id: p1, name: p1 },
|
||||
participant2: { id: p2, name: p2 },
|
||||
winner: opts.missingWinnerRelation ? null : { id: winnerId, name: winnerId },
|
||||
loser: { id: loserId, name: loserId },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A scheduled-but-unplayed match. Bracket rows are pre-generated with their slots
|
||||
* filled as earlier rounds resolve, so an unplayed 3PG still lists both SF losers.
|
||||
*/
|
||||
function makePendingMatch(
|
||||
round: string,
|
||||
matchNumber: number,
|
||||
participant1Id: string | null,
|
||||
participant2Id: string | null
|
||||
): Match {
|
||||
return {
|
||||
id: `${round}-${matchNumber}`,
|
||||
round,
|
||||
matchNumber,
|
||||
participant1Id,
|
||||
participant2Id,
|
||||
winnerId: null,
|
||||
loserId: null,
|
||||
isComplete: false,
|
||||
participant1Score: null,
|
||||
participant2Score: null,
|
||||
participant1: participant1Id ? { id: participant1Id, name: participant1Id } : null,
|
||||
participant2: participant2Id ? { id: participant2Id, name: participant2Id } : null,
|
||||
winner: null,
|
||||
loser: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A full fifa_48-shaped knockout tail: 4 QF, 2 SF, the 3PG, and the Final. */
|
||||
function fifaMatches(
|
||||
overrides: { played?: boolean; thirdPlace?: Match } = {}
|
||||
): Match[] {
|
||||
const played = overrides.played ?? true;
|
||||
return [
|
||||
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
|
||||
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
|
||||
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
|
||||
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
|
||||
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
|
||||
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
|
||||
overrides.thirdPlace ??
|
||||
(played
|
||||
? makeRankedMatch("Third Place Game", 1, "sfB", "sfD")
|
||||
: makePendingMatch("Third Place Game", 1, "sfB", "sfD")),
|
||||
played
|
||||
? makeRankedMatch("Finals", 1, "sfA", "sfC")
|
||||
: makePendingMatch("Finals", 1, "sfA", "sfC"),
|
||||
];
|
||||
}
|
||||
|
||||
/** Rank the fifa fixture, defaulting to the fifa_48 consolation config. */
|
||||
function rankFifa(
|
||||
matches: Match[] = fifaMatches(),
|
||||
ownership: Map<string, { participantId: string; teamName: string; teamId: string }> = new Map(),
|
||||
consolation: typeof FIFA_CONSOLATION | undefined = FIFA_CONSOLATION
|
||||
) {
|
||||
return computeRankedEntries(
|
||||
matches,
|
||||
FIFA_ROUNDS,
|
||||
groupMatchesByRound(matches),
|
||||
consolation,
|
||||
ownership
|
||||
);
|
||||
}
|
||||
|
||||
function rankOf(entries: ReturnType<typeof computeRankedEntries>, id: string) {
|
||||
return entries.find((e) => e.participant.id === id)?.rankLabel;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// llws_20 — double elimination, where a winners-bracket loss places nobody
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Stable participant id for an llws_20 bracket slot. */
|
||||
function llwsTeam(i: number): string {
|
||||
return `t${String(i).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a full 20-team LLWS tournament, always advancing the lower-numbered
|
||||
* participant id so the outcome is deterministic, and return every match.
|
||||
* Routing comes from the real advancement map rather than being hand-listed.
|
||||
*/
|
||||
function llwsMatches(): Match[] {
|
||||
const template = getBracketTemplate("llws_20");
|
||||
if (!template) throw new Error("llws_20 template missing");
|
||||
|
||||
// round → matchNumber → [p1, p2]
|
||||
const slots = new Map<string, Map<number, [string | null, string | null]>>();
|
||||
for (const round of template.rounds) {
|
||||
const byNumber = new Map<number, [string | null, string | null]>();
|
||||
for (let n = 1; n <= round.matchCount; n++) byNumber.set(n, [null, null]);
|
||||
slots.set(round.name, byNumber);
|
||||
}
|
||||
const put = (round: string, n: number, slot: 0 | 1, id: string) => {
|
||||
const pair = slots.get(round)?.get(n);
|
||||
if (pair) pair[slot] = id;
|
||||
};
|
||||
|
||||
// Seed the Opening Round and the four byes, mirroring generateLLWS20Bracket.
|
||||
for (const [base, roundBase] of [[0, 1], [10, 5]] as const) {
|
||||
for (let local = 0; local < 4; local++) {
|
||||
put("Opening Round", roundBase + local, 0, llwsTeam(base + local * 2));
|
||||
put("Opening Round", roundBase + local, 1, llwsTeam(base + local * 2 + 1));
|
||||
}
|
||||
}
|
||||
put("Winners Round 2", 1, 0, llwsTeam(8));
|
||||
put("Winners Round 2", 2, 0, llwsTeam(9));
|
||||
put("Winners Round 2", 3, 0, llwsTeam(18));
|
||||
put("Winners Round 2", 4, 0, llwsTeam(19));
|
||||
|
||||
const matches: Match[] = [];
|
||||
for (const round of template.rounds) {
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const [p1, p2] = slots.get(round.name)?.get(n) ?? [null, null];
|
||||
if (!p1 || !p2) throw new Error(`${round.name} #${n} was not filled`);
|
||||
// Deterministic: the lower id always wins.
|
||||
const winnerId = p1 < p2 ? p1 : p2;
|
||||
const loserId = p1 < p2 ? p2 : p1;
|
||||
matches.push(
|
||||
makeRankedMatch(round.name, n, winnerId, loserId, {
|
||||
winnerSlot: winnerId === p1 ? 1 : 2,
|
||||
})
|
||||
);
|
||||
const { winner, loser } = resolveLLWSAdvancement(round.name, n);
|
||||
if (winner) put(winner.round, winner.matchNumber, winner.slot === "participant1Id" ? 0 : 1, winnerId);
|
||||
if (loser) put(loser.round, loser.matchNumber, loser.slot === "participant1Id" ? 0 : 1, loserId);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
describe("computeRankedEntries — llws_20 double elimination", () => {
|
||||
const template = getBracketTemplate("llws_20");
|
||||
const rounds = template?.rounds.map((r) => r.name) ?? [];
|
||||
|
||||
function rankLlws() {
|
||||
const matches = llwsMatches();
|
||||
const consolation = findConsolationRound(template);
|
||||
return computeRankedEntries(
|
||||
matches,
|
||||
rounds,
|
||||
groupMatchesByRound(matches),
|
||||
consolation,
|
||||
new Map(),
|
||||
roundsWithLosersPlacedLater(template, consolation)
|
||||
);
|
||||
}
|
||||
|
||||
it("ranks all 19 non-champions exactly once", () => {
|
||||
const entries = rankLlws();
|
||||
expect(entries).toHaveLength(19);
|
||||
expect(new Set(entries.map((e) => e.participant.id)).size).toBe(19);
|
||||
});
|
||||
|
||||
it("gives the top 8 the positions the scoring tiers depend on", () => {
|
||||
const entries = rankLlws();
|
||||
const labels = entries.map((e) => e.rankLabel);
|
||||
// 2nd (World Championship loser), then 3rd and 4th decided by the consolation
|
||||
// game, then the two 5–6 and two 7–8 tier teams.
|
||||
expect(labels[0]).toBe("T2");
|
||||
expect(labels.filter((l) => l === "3")).toHaveLength(1);
|
||||
expect(labels.filter((l) => l === "4")).toHaveLength(1);
|
||||
expect(labels.filter((l) => l === "T5")).toHaveLength(2);
|
||||
expect(labels.filter((l) => l === "T7")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not inflate positions below the top 8", () => {
|
||||
// Winners-bracket losses place nobody — those teams are ranked by the
|
||||
// elimination-bracket game that actually knocks them out. If the winners
|
||||
// rounds consumed positions, the last tier would read T23 in a 20-team field.
|
||||
const entries = rankLlws();
|
||||
const labels = entries.map((e) => e.rankLabel);
|
||||
expect(labels.filter((l) => l === "T9")).toHaveLength(4);
|
||||
expect(labels.filter((l) => l === "T13")).toHaveLength(4);
|
||||
expect(labels.filter((l) => l === "T17")).toHaveLength(4);
|
||||
// 1 champion (not in the list) + 19 ranked = the full 20-team field.
|
||||
expect(labels.some((l) => Number(l.replace("T", "")) > 17)).toBe(false);
|
||||
});
|
||||
|
||||
it("never ranks a winners-bracket loser at the round they first lost", () => {
|
||||
const entries = rankLlws();
|
||||
// t00 wins every game it plays (lowest id), so take a team that loses in the
|
||||
// winners bracket but survives: the Opening Round M1 loser, t01.
|
||||
const t01 = entries.find((e) => e.participant.id === "t01");
|
||||
expect(t01).toBeDefined();
|
||||
// Losing the opening game must not park them in the bottom tier — they got a
|
||||
// second life in the elimination bracket.
|
||||
expect(t01?.rankLabel).not.toBe("T17");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findConsolationRound", () => {
|
||||
it("identifies the fifa_48 third place game and the round that feeds it", () => {
|
||||
expect(findConsolationRound(getBracketTemplate("fifa_48"))).toEqual({
|
||||
round: "Third Place Game",
|
||||
feederRound: "Semifinals",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns undefined for a template with no consolation round", () => {
|
||||
expect(findConsolationRound(getBracketTemplate("ncaa_64"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when there is no template", () => {
|
||||
expect(findConsolationRound(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores double-elimination loser routing and finds the real consolation game", () => {
|
||||
// llws_20 sets loserFeedsInto on every winners-bracket round to route losers
|
||||
// into the elimination bracket. Only the Bracket Championship feeds a terminal
|
||||
// round; taking the first loserFeedsInto instead would mistake "Elimination
|
||||
// Round 1" for a third-place game and corrupt the final rankings.
|
||||
expect(findConsolationRound(getBracketTemplate("llws_20"))).toEqual({
|
||||
round: "Consolation Third Place",
|
||||
feederRound: "Bracket Championship",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeRankedEntries", () => {
|
||||
describe("fifa_48 third place game", () => {
|
||||
it("ranks the third place game winner 3rd — they never lose a match after the SF", () => {
|
||||
// sfB lost the semifinal, then won the 3PG.
|
||||
expect(rankOf(rankFifa(), "sfB")).toBe("3");
|
||||
});
|
||||
|
||||
it("ranks the third place game loser 4th, not 3rd", () => {
|
||||
expect(rankOf(rankFifa(), "sfD")).toBe("4");
|
||||
});
|
||||
|
||||
it("gives quarterfinal losers T5 — the 3PG consumes no positions of its own", () => {
|
||||
const entries = rankFifa();
|
||||
expect(rankOf(entries, "qf1")).toBe("T5");
|
||||
expect(rankOf(entries, "qf2")).toBe("T5");
|
||||
expect(rankOf(entries, "qf3")).toBe("T5");
|
||||
expect(rankOf(entries, "qf4")).toBe("T5");
|
||||
});
|
||||
|
||||
it("ranks the finals loser 2nd and leaves the champion out of the list", () => {
|
||||
const entries = rankFifa();
|
||||
expect(rankOf(entries, "sfC")).toBe("T2");
|
||||
expect(rankOf(entries, "sfA")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("orders the list by rank: 2nd, 3rd, 4th, then the T5 tier", () => {
|
||||
expect(rankFifa().map((e) => e.rankLabel)).toEqual([
|
||||
"T2",
|
||||
"3",
|
||||
"4",
|
||||
"T5",
|
||||
"T5",
|
||||
"T5",
|
||||
"T5",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves semifinal losers unranked until the third place game is played", () => {
|
||||
const entries = rankFifa(fifaMatches({ played: false }));
|
||||
// Both SF losers are still alive for the 3PG.
|
||||
expect(rankOf(entries, "sfB")).toBeUndefined();
|
||||
expect(rankOf(entries, "sfD")).toBeUndefined();
|
||||
// QF losers are still T5 — the later rounds still consume their positions.
|
||||
expect(rankOf(entries, "qf1")).toBe("T5");
|
||||
});
|
||||
|
||||
it("carries ownership through onto the third place entries", () => {
|
||||
const ownership = new Map([
|
||||
["sfB", { participantId: "sfB", teamName: "Team Nine", teamId: "t9" }],
|
||||
]);
|
||||
const entries = rankFifa(fifaMatches(), ownership);
|
||||
expect(entries.find((e) => e.participant.id === "sfB")?.ownership?.teamName).toBe(
|
||||
"Team Nine"
|
||||
);
|
||||
expect(entries.find((e) => e.participant.id === "sfD")?.ownership).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scores", () => {
|
||||
it("reads each participant's own score regardless of which slot they occupied", () => {
|
||||
const matches = fifaMatches({
|
||||
// Winner sits in slot 2 this time, so a slot-blind lookup would swap the scores.
|
||||
thirdPlace: makeRankedMatch("Third Place Game", 1, "sfB", "sfD", {
|
||||
winnerSlot: 2,
|
||||
winnerScore: "3",
|
||||
loserScore: "1",
|
||||
}),
|
||||
});
|
||||
const entries = rankFifa(matches);
|
||||
|
||||
expect(entries.find((e) => e.participant.id === "sfB")?.score).toBe("3");
|
||||
expect(entries.find((e) => e.participant.id === "sfD")?.score).toBe("1");
|
||||
});
|
||||
|
||||
it("reads a loser's score from the slot they actually played in", () => {
|
||||
const matches: Match[] = [
|
||||
makeRankedMatch("Semifinals", 1, "sfA", "sfB", {
|
||||
winnerSlot: 2,
|
||||
winnerScore: "4",
|
||||
loserScore: "2",
|
||||
}),
|
||||
];
|
||||
const entries = computeRankedEntries(
|
||||
matches,
|
||||
["Semifinals"],
|
||||
groupMatchesByRound(matches),
|
||||
undefined,
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(entries.find((e) => e.participant.id === "sfB")?.score).toBe("2");
|
||||
});
|
||||
|
||||
it("reports no score for a participant who occupies neither slot", () => {
|
||||
// A stale row after a bracket edit: loserId no longer matches either slot.
|
||||
// Attributing the other team's score here would look entirely plausible.
|
||||
const stale: Match = {
|
||||
...makeRankedMatch("Semifinals", 1, "sfA", "sfB", {
|
||||
winnerScore: "4",
|
||||
loserScore: "2",
|
||||
}),
|
||||
loserId: "ghost",
|
||||
loser: { id: "ghost", name: "ghost" },
|
||||
};
|
||||
const entries = computeRankedEntries(
|
||||
[stale],
|
||||
["Semifinals"],
|
||||
groupMatchesByRound([stale]),
|
||||
undefined,
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(entries.find((e) => e.participant.id === "ghost")?.score).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a consolation round somewhere other than 3rd/4th", () => {
|
||||
// No template ships this today, but the positions must come from the feeder
|
||||
// round rather than being hardcoded to 3 and 4.
|
||||
const ROUNDS = ["Quarterfinals", "Semifinals", "Fifth Place Game", "Finals"];
|
||||
const CONSOLATION = { round: "Fifth Place Game", feederRound: "Quarterfinals" };
|
||||
|
||||
const matches: Match[] = [
|
||||
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
|
||||
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
|
||||
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
|
||||
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
|
||||
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
|
||||
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
|
||||
makeRankedMatch("Fifth Place Game", 1, "qf1", "qf2"),
|
||||
makeRankedMatch("Finals", 1, "sfA", "sfC"),
|
||||
];
|
||||
|
||||
it("places the consolation pair at its feeder round's tier, not at 3rd and 4th", () => {
|
||||
const entries = computeRankedEntries(
|
||||
matches,
|
||||
ROUNDS,
|
||||
groupMatchesByRound(matches),
|
||||
CONSOLATION,
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(rankOf(entries, "qf1")).toBe("5");
|
||||
expect(rankOf(entries, "qf2")).toBe("6");
|
||||
// The feeder round's other losers start below the pair, not alongside them.
|
||||
expect(rankOf(entries, "qf3")).toBe("T7");
|
||||
expect(rankOf(entries, "qf4")).toBe("T7");
|
||||
// The rounds above it are unaffected.
|
||||
expect(rankOf(entries, "sfC")).toBe("T2");
|
||||
expect(rankOf(entries, "sfB")).toBe("T3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("consolation matches that cannot be placed exactly", () => {
|
||||
it("still ranks the loser when the winner relation is missing", () => {
|
||||
const matches = fifaMatches({
|
||||
thirdPlace: makeRankedMatch("Third Place Game", 1, "sfB", "sfD", {
|
||||
missingWinnerRelation: true,
|
||||
}),
|
||||
});
|
||||
const entries = rankFifa(matches);
|
||||
|
||||
// The winner cannot be placed without a participant object, but the loser must
|
||||
// not silently vanish the way it would if the round were skipped wholesale.
|
||||
expect(rankOf(entries, "sfD")).toBeDefined();
|
||||
});
|
||||
|
||||
it("falls back to the loser-driven walk when the feeder round has no matches", () => {
|
||||
const matches: Match[] = [
|
||||
makeRankedMatch("Third Place Game", 1, "sfB", "sfD"),
|
||||
makeRankedMatch("Finals", 1, "sfA", "sfC"),
|
||||
];
|
||||
const entries = computeRankedEntries(
|
||||
matches,
|
||||
["Third Place Game", "Finals"],
|
||||
groupMatchesByRound(matches),
|
||||
FIFA_CONSOLATION,
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(rankOf(entries, "sfC")).toBe("T2");
|
||||
expect(rankOf(entries, "sfD")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("rendered output", () => {
|
||||
/** The card the 3PG winner was incorrectly appearing in. */
|
||||
function inContentionNames() {
|
||||
const card = screen.queryByText("In Contention")?.closest('[data-slot="card"]');
|
||||
if (!card) return [];
|
||||
return within(card as HTMLElement)
|
||||
.getAllByRole("row")
|
||||
.map((r) => r.textContent ?? "");
|
||||
}
|
||||
|
||||
it("does not list the third place game winner as in contention", () => {
|
||||
render(
|
||||
<PlayoffBracket matches={fifaMatches()} rounds={FIFA_ROUNDS} bracketTemplateId="fifa_48" />
|
||||
);
|
||||
|
||||
// sfB won the third place game — they are finished, not still playing.
|
||||
expect(inContentionNames().some((t) => t.includes("sfB"))).toBe(false);
|
||||
});
|
||||
|
||||
it("still lists semifinalists as in contention before the third place game", () => {
|
||||
render(
|
||||
<PlayoffBracket
|
||||
matches={fifaMatches({ played: false })}
|
||||
rounds={FIFA_ROUNDS}
|
||||
bracketTemplateId="fifa_48"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(inContentionNames().some((t) => t.includes("sfB"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("brackets without a consolation round", () => {
|
||||
const ROUNDS = ["Quarterfinals", "Semifinals", "Finals"];
|
||||
|
||||
it("ranks losers by round with tie labels, unchanged", () => {
|
||||
const matches: Match[] = [
|
||||
makeRankedMatch("Quarterfinals", 1, "sfA", "qf1"),
|
||||
makeRankedMatch("Quarterfinals", 2, "sfB", "qf2"),
|
||||
makeRankedMatch("Quarterfinals", 3, "sfC", "qf3"),
|
||||
makeRankedMatch("Quarterfinals", 4, "sfD", "qf4"),
|
||||
makeRankedMatch("Semifinals", 1, "sfA", "sfB"),
|
||||
makeRankedMatch("Semifinals", 2, "sfC", "sfD"),
|
||||
makeRankedMatch("Finals", 1, "sfA", "sfC"),
|
||||
];
|
||||
|
||||
const entries = computeRankedEntries(
|
||||
matches,
|
||||
ROUNDS,
|
||||
groupMatchesByRound(matches),
|
||||
undefined,
|
||||
new Map()
|
||||
);
|
||||
|
||||
expect(rankOf(entries, "sfC")).toBe("T2");
|
||||
expect(rankOf(entries, "sfB")).toBe("T3");
|
||||
expect(rankOf(entries, "sfD")).toBe("T3");
|
||||
expect(rankOf(entries, "qf1")).toBe("T5");
|
||||
expect(rankOf(entries, "sfA")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import type { GroupStandingsRow } from "~/models/group-stage-match";
|
||||
import { utcIsoToLocalDateTime } from "~/lib/date-utils";
|
||||
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
|
||||
|
||||
interface GroupMatch {
|
||||
|
|
@ -101,12 +100,6 @@ export function GroupStageStandings({
|
|||
ownershipMap,
|
||||
showEmpty = false,
|
||||
}: GroupStageStandingsProps) {
|
||||
// SSR (and first client paint) groups/labels by UTC date for deterministic markup;
|
||||
// after hydration we switch to the viewer's local date so evening matches don't roll
|
||||
// into the wrong day header.
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const visibleGroups = showEmpty
|
||||
? groups
|
||||
: groups.filter((g) => g.matches.some((m) => m.isComplete) || g.standings.length > 0);
|
||||
|
|
@ -193,35 +186,25 @@ export function GroupStageStandings({
|
|||
if (!b.scheduledAt) return -1;
|
||||
return new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime();
|
||||
});
|
||||
// Key by UTC date on SSR/first paint (deterministic); after mount, key by
|
||||
// the viewer's local date so the day headers match local wall-clock.
|
||||
// Use the UTC date portion as a stable key so server and client always
|
||||
// produce the same Map structure regardless of the user's timezone.
|
||||
const byDay = new Map<string, GroupMatch[]>();
|
||||
for (const m of sorted) {
|
||||
const key = m.scheduledAt
|
||||
? (mounted
|
||||
? utcIsoToLocalDateTime(m.scheduledAt).slice(0, 10)
|
||||
: m.scheduledAt.slice(0, 10))
|
||||
: "TBD";
|
||||
const key = m.scheduledAt ? m.scheduledAt.slice(0, 10) : "TBD";
|
||||
if (!byDay.has(key)) byDay.set(key, []);
|
||||
byDay.get(key)?.push(m);
|
||||
}
|
||||
return (
|
||||
<div className="px-4 pb-3 space-y-0.5 border-t pt-2">
|
||||
{[...byDay.entries()].map(([key, matches]) => {
|
||||
// Before mount the key is a UTC date (format in UTC); after mount it is
|
||||
// a local date (format in local time) so the label matches the grouping.
|
||||
// Format from the UTC date string so label is identical on server and client.
|
||||
const dayLabel = key === "TBD"
|
||||
? "TBD"
|
||||
: mounted
|
||||
? new Date(key + "T12:00:00").toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: new Date(key + "T12:00:00Z").toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
: new Date(key + "T12:00:00Z").toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
return (
|
||||
<div key={key} className="space-y-0.5">
|
||||
<p className="text-[10px] text-muted-foreground/70 uppercase tracking-wide pt-1">
|
||||
|
|
|
|||
|
|
@ -1,15 +1,7 @@
|
|||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent } from "~/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "~/components/ui/table";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
|
||||
|
|
@ -105,54 +97,6 @@ function sortPicks(
|
|||
});
|
||||
}
|
||||
|
||||
type Pick = TeamScoreBreakdownProps["breakdown"]["picks"][number];
|
||||
|
||||
const SORT_OPTIONS: Array<{ value: SortColumn; label: string }> = [
|
||||
{ value: "pick", label: "Pick #" },
|
||||
{ value: "sport", label: "Sport" },
|
||||
{ value: "participant", label: "Participant" },
|
||||
{ value: "points", label: "Points" },
|
||||
];
|
||||
|
||||
function pickLabel(pick: Pick, numTeams: number): string {
|
||||
return numTeams > 0
|
||||
? `${pick.round}.${String(pick.pickNumber - (pick.round - 1) * numTeams).padStart(2, "0")}`
|
||||
: `#${pick.pickNumber}`;
|
||||
}
|
||||
|
||||
/** Position badge shared by the desktop table and the mobile cards. */
|
||||
function PositionCell({ pick }: { pick: Pick }) {
|
||||
if (pick.isComplete && !pick.isPartialScore) {
|
||||
return (pick.finalPosition ?? 0) === 0 ? (
|
||||
<Badge variant="secondary">Did Not Score</Badge>
|
||||
) : (
|
||||
<PlacementBadge position={pick.finalPosition ?? 0} />
|
||||
);
|
||||
}
|
||||
return <Badge variant="outline">Pending</Badge>;
|
||||
}
|
||||
|
||||
/** Points value (actual, with projected underneath when incomplete). */
|
||||
function PointsValue({ pick }: { pick: Pick }) {
|
||||
if (pick.isComplete && !pick.isPartialScore) {
|
||||
return (
|
||||
<span className="font-semibold">
|
||||
{pick.points > 0 ? pick.points.toFixed(2) : "0.00"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="font-semibold">{pick.points.toFixed(2)}</span>
|
||||
{pick.projectedPoints !== null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{pick.projectedPoints.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display detailed team score breakdown with all drafted participants
|
||||
* Phase 4.3: Team breakdown pages
|
||||
|
|
@ -186,23 +130,17 @@ export function TeamScoreBreakdown({
|
|||
}
|
||||
}
|
||||
|
||||
// Mobile sort control: picking a column applies its default direction.
|
||||
function handleSortColumn(column: SortColumn) {
|
||||
setSortColumn(column);
|
||||
setSortDirection(column === "points" ? "desc" : "asc");
|
||||
}
|
||||
|
||||
const allPicks = sortPicks(breakdown.picks, sortColumn, sortDirection);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold">{breakdown.team.name}</h1>
|
||||
<h1 className="text-3xl font-bold">{breakdown.team.name}</h1>
|
||||
<p className="text-muted-foreground mt-1">Team Score Breakdown</p>
|
||||
</div>
|
||||
<div className="text-left sm:text-right">
|
||||
<div className="text-right">
|
||||
<div className="text-4xl font-bold text-primary">
|
||||
{breakdown.actualPoints.toFixed(2)}
|
||||
</div>
|
||||
|
|
@ -226,8 +164,8 @@ export function TeamScoreBreakdown({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* All picks — desktop table */}
|
||||
<Card className="hidden md:block">
|
||||
{/* All picks — single flat table */}
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
|
|
@ -260,7 +198,9 @@ export function TeamScoreBreakdown({
|
|||
{allPicks.map((pick) => (
|
||||
<TableRow key={pick.pickNumber}>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{pickLabel(pick, numTeams)}
|
||||
{numTeams > 0
|
||||
? `${pick.round}.${String(pick.pickNumber - (pick.round - 1) * numTeams).padStart(2, "0")}`
|
||||
: `#${pick.pickNumber}`}
|
||||
<span className="text-xs ml-1">(#{pick.pickNumber})</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
|
@ -275,10 +215,31 @@ export function TeamScoreBreakdown({
|
|||
{pick.participant.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<PositionCell pick={pick} />
|
||||
{pick.isComplete && !pick.isPartialScore ? (
|
||||
(pick.finalPosition ?? 0) === 0 ? (
|
||||
<Badge variant="secondary">Did Not Score</Badge>
|
||||
) : (
|
||||
<PlacementBadge position={pick.finalPosition ?? 0} />
|
||||
)
|
||||
) : (
|
||||
<Badge variant="outline">Pending</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<PointsValue pick={pick} />
|
||||
{pick.isComplete && !pick.isPartialScore ? (
|
||||
<span className="font-semibold">
|
||||
{pick.points > 0 ? pick.points.toFixed(2) : "0.00"}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="font-semibold">{pick.points.toFixed(2)}</span>
|
||||
{pick.projectedPoints !== null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{pick.projectedPoints.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
|
@ -287,70 +248,6 @@ export function TeamScoreBreakdown({
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* All picks — mobile cards (no horizontal scroll, all fields visible) */}
|
||||
<div className="md:hidden space-y-3" data-testid="picks-mobile">
|
||||
{/* Sort control */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span id="picks-sort-label" className="text-sm text-muted-foreground shrink-0">
|
||||
Sort by
|
||||
</span>
|
||||
<Select
|
||||
value={sortColumn}
|
||||
onValueChange={(v) => handleSortColumn(v as SortColumn)}
|
||||
>
|
||||
<SelectTrigger className="flex-1" aria-labelledby="picks-sort-label">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setSortDirection(sortDirection === "asc" ? "desc" : "asc")}
|
||||
aria-label={`Sort ${sortDirection === "asc" ? "descending" : "ascending"}`}
|
||||
>
|
||||
{sortDirection === "asc" ? (
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{allPicks.map((pick) => (
|
||||
<Card key={pick.pickNumber} className="py-0" data-testid="pick-card">
|
||||
<CardContent className="p-3 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{pickLabel(pick, numTeams)}
|
||||
<span className="text-xs ml-1">(#{pick.pickNumber})</span>
|
||||
</div>
|
||||
<PositionCell pick={pick} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium break-words">{pick.participant.name}</div>
|
||||
<Link
|
||||
to={`/leagues/${leagueId}/sports-seasons/${pick.participant.sportsSeasonId}`}
|
||||
className="text-sm font-medium hover:underline text-primary"
|
||||
>
|
||||
{pick.participant.sport}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between border-t border-border/30 pt-2">
|
||||
<span className="text-sm text-muted-foreground">Points</span>
|
||||
<PointsValue pick={pick} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<Link
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { BrowserRouter } from "react-router";
|
||||
import { TeamScoreBreakdown } from "../TeamScoreBreakdown";
|
||||
|
|
@ -160,10 +160,9 @@ describe("TeamScoreBreakdown", () => {
|
|||
/>
|
||||
);
|
||||
|
||||
// Pick labels render in both the desktop table and mobile cards.
|
||||
expect(screen.getAllByText("1.01").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("1.02").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("2.01").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("1.01")).toBeInTheDocument();
|
||||
expect(screen.getByText("1.02")).toBeInTheDocument();
|
||||
expect(screen.getByText("2.01")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show overall pick number in parentheses", () => {
|
||||
|
|
@ -177,9 +176,9 @@ describe("TeamScoreBreakdown", () => {
|
|||
/>
|
||||
);
|
||||
|
||||
expect(screen.getAllByText("(#1)").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("(#2)").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("(#3)").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("(#1)")).toBeInTheDocument();
|
||||
expect(screen.getByText("(#2)")).toBeInTheDocument();
|
||||
expect(screen.getByText("(#3)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should fall back to #pickNumber when numTeams is 0", () => {
|
||||
|
|
@ -193,9 +192,9 @@ describe("TeamScoreBreakdown", () => {
|
|||
/>
|
||||
);
|
||||
|
||||
expect(screen.getAllByText("#1").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("#2").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("#3").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("#1")).toBeInTheDocument();
|
||||
expect(screen.getByText("#2")).toBeInTheDocument();
|
||||
expect(screen.getByText("#3")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -211,10 +210,9 @@ describe("TeamScoreBreakdown", () => {
|
|||
/>
|
||||
);
|
||||
|
||||
const table = within(screen.getByRole("table"));
|
||||
expect(table.getByText("Team A")).toBeInTheDocument();
|
||||
expect(table.getByText("Driver B")).toBeInTheDocument();
|
||||
expect(table.getByText("Team C")).toBeInTheDocument();
|
||||
expect(screen.getByText("Team A")).toBeInTheDocument();
|
||||
expect(screen.getByText("Driver B")).toBeInTheDocument();
|
||||
expect(screen.getByText("Team C")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show sport names as links to sport season pages", () => {
|
||||
|
|
@ -228,15 +226,14 @@ describe("TeamScoreBreakdown", () => {
|
|||
/>
|
||||
);
|
||||
|
||||
const table = within(screen.getByRole("table"));
|
||||
const nflLinks = table.getAllByRole("link", { name: "NFL" });
|
||||
const nflLinks = screen.getAllByRole("link", { name: "NFL" });
|
||||
expect(nflLinks.length).toBeGreaterThanOrEqual(1);
|
||||
expect(nflLinks[0]).toHaveAttribute(
|
||||
"href",
|
||||
`/leagues/${mockLeagueId}/sports-seasons/ss-nfl`
|
||||
);
|
||||
|
||||
const f1Link = table.getByRole("link", { name: "F1" });
|
||||
const f1Link = screen.getByRole("link", { name: "F1" });
|
||||
expect(f1Link).toHaveAttribute(
|
||||
"href",
|
||||
`/leagues/${mockLeagueId}/sports-seasons/ss-f1`
|
||||
|
|
@ -254,9 +251,8 @@ describe("TeamScoreBreakdown", () => {
|
|||
/>
|
||||
);
|
||||
|
||||
const table = within(screen.getByRole("table"));
|
||||
expect(table.getByText("1st")).toBeInTheDocument();
|
||||
expect(table.getByText("3rd")).toBeInTheDocument();
|
||||
expect(screen.getByText("1st")).toBeInTheDocument();
|
||||
expect(screen.getByText("3rd")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Pending badge for incomplete participants", () => {
|
||||
|
|
@ -270,8 +266,7 @@ describe("TeamScoreBreakdown", () => {
|
|||
/>
|
||||
);
|
||||
|
||||
const table = within(screen.getByRole("table"));
|
||||
expect(table.getByText("Pending")).toBeInTheDocument();
|
||||
expect(screen.getByText("Pending")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Did Not Score badge when finalPosition is 0", () => {
|
||||
|
|
@ -296,8 +291,7 @@ describe("TeamScoreBreakdown", () => {
|
|||
/>
|
||||
);
|
||||
|
||||
const table = within(screen.getByRole("table"));
|
||||
expect(table.getByText("Did Not Score")).toBeInTheDocument();
|
||||
expect(screen.getByText("Did Not Score")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Did Not Score badge when isComplete but finalPosition is null", () => {
|
||||
|
|
@ -324,8 +318,7 @@ describe("TeamScoreBreakdown", () => {
|
|||
/>
|
||||
);
|
||||
|
||||
const table = within(screen.getByRole("table"));
|
||||
expect(table.getByText("Did Not Score")).toBeInTheDocument();
|
||||
expect(screen.getByText("Did Not Score")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show projected EV for incomplete participants", () => {
|
||||
|
|
@ -340,9 +333,8 @@ describe("TeamScoreBreakdown", () => {
|
|||
);
|
||||
|
||||
// Incomplete participant shows 0.00 (actual) and 25.00 (EV) in the row
|
||||
const table = within(screen.getByRole("table"));
|
||||
expect(table.getByText("0.00")).toBeInTheDocument();
|
||||
expect(table.getByText("25.00")).toBeInTheDocument();
|
||||
expect(screen.getByText("0.00")).toBeInTheDocument();
|
||||
expect(screen.getByText("25.00")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display 0.00 for completed picks with zero points", () => {
|
||||
|
|
@ -361,8 +353,7 @@ describe("TeamScoreBreakdown", () => {
|
|||
/>
|
||||
);
|
||||
|
||||
const table = within(screen.getByRole("table"));
|
||||
expect(table.getByText("0.00")).toBeInTheDocument();
|
||||
expect(screen.getByText("0.00")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -510,96 +501,6 @@ describe("TeamScoreBreakdown", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Mobile view", () => {
|
||||
function getCardOrder() {
|
||||
const mobile = screen.getByTestId("picks-mobile");
|
||||
const cards = within(mobile).getAllByTestId("pick-card");
|
||||
return cards.map((card) =>
|
||||
["Team A", "Driver B", "Team C"].find((n) => card.textContent?.includes(n))
|
||||
);
|
||||
}
|
||||
|
||||
async function selectSortColumn(user: ReturnType<typeof userEvent.setup>, label: string) {
|
||||
await user.click(screen.getByRole("combobox", { name: /sort by/i }));
|
||||
await user.click(screen.getByRole("option", { name: label }));
|
||||
}
|
||||
|
||||
it("renders a card per pick with all fields visible", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
const mobile = within(screen.getByTestId("picks-mobile"));
|
||||
expect(mobile.getAllByTestId("pick-card")).toHaveLength(3);
|
||||
// Participant names, sports, positions all visible without hiding data
|
||||
expect(mobile.getByText("Team A")).toBeInTheDocument();
|
||||
expect(mobile.getByText("Driver B")).toBeInTheDocument();
|
||||
expect(mobile.getByText("Team C")).toBeInTheDocument();
|
||||
expect(mobile.getByText("1st")).toBeInTheDocument();
|
||||
expect(mobile.getByText("Pending")).toBeInTheDocument();
|
||||
expect(mobile.getByRole("link", { name: "F1" })).toHaveAttribute(
|
||||
"href",
|
||||
`/leagues/${mockLeagueId}/sports-seasons/ss-f1`
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to pick order", () => {
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(getCardOrder()).toEqual(["Team A", "Driver B", "Team C"]);
|
||||
});
|
||||
|
||||
it("reorders cards when sorting by points (desc) via the select", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
// picks: Team A=100, Driver B=50, Team C=0; points defaults to descending
|
||||
await selectSortColumn(user, "Points");
|
||||
expect(getCardOrder()).toEqual(["Team A", "Driver B", "Team C"]);
|
||||
});
|
||||
|
||||
it("toggles sort direction with the direction button", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithRouter(
|
||||
<TeamScoreBreakdown
|
||||
leagueId={mockLeagueId}
|
||||
seasonId={mockSeasonId}
|
||||
numTeams={numTeams}
|
||||
breakdown={mockBreakdown}
|
||||
standing={mockStanding}
|
||||
/>
|
||||
);
|
||||
|
||||
await selectSortColumn(user, "Points"); // desc
|
||||
// While descending, the toggle's action is to sort ascending.
|
||||
await user.click(screen.getByRole("button", { name: /sort ascending/i }));
|
||||
// now ascending: Team C (0), Driver B (50), Team A (100)
|
||||
expect(getCardOrder()).toEqual(["Team C", "Driver B", "Team A"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Without Standing Data", () => {
|
||||
it("should render without standing prop", () => {
|
||||
renderWithRouter(
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function AccountSection({ email, linkedAccounts }: Props) {
|
|||
setLinkingDiscord(true);
|
||||
setLinkError(null);
|
||||
try {
|
||||
await authClient.linkSocial({ provider: "discord", callbackURL: "/settings/account" });
|
||||
await authClient.linkSocial({ provider: "discord", callbackURL: "/settings?section=account" });
|
||||
} catch {
|
||||
setLinkError("Failed to connect Discord. Please try again.");
|
||||
setLinkingDiscord(false);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Link, useFetcher } from "react-router";
|
||||
import { useFetcher } from "react-router";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { Label } from "~/components/ui/label";
|
||||
|
||||
|
|
@ -14,9 +14,10 @@ type Props = {
|
|||
discordPingEnabled: boolean;
|
||||
hasDiscordLinked: boolean;
|
||||
draftEmailNotificationsEnabled: boolean;
|
||||
onNavigateToAccount: () => void;
|
||||
};
|
||||
|
||||
export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, draftEmailNotificationsEnabled }: Props) {
|
||||
export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, draftEmailNotificationsEnabled, onNavigateToAccount }: Props) {
|
||||
const discordFetcher = useFetcher();
|
||||
const emailFetcher = useFetcher();
|
||||
|
||||
|
|
@ -86,12 +87,13 @@ export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, dra
|
|||
{!hasDiscordLinked ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Link your Discord account in{" "}
|
||||
<Link
|
||||
to="/settings/account"
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNavigateToAccount}
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
Account settings
|
||||
</Link>{" "}
|
||||
</button>{" "}
|
||||
to enable Discord pings for league notifications.
|
||||
</p>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ describe("AccountSection", () => {
|
|||
await waitFor(() => {
|
||||
expect(mockLinkSocial).toHaveBeenCalledWith({
|
||||
provider: "discord",
|
||||
callbackURL: "/settings/account",
|
||||
callbackURL: "/settings?section=account",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,26 +1,18 @@
|
|||
import * as Sentry from "@sentry/react-router";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { shouldReportServerError } from "~/lib/error-reporting";
|
||||
|
||||
import type { AppLoadContext, EntryContext, HandleErrorFunction } from "react-router";
|
||||
import type { AppLoadContext, EntryContext } from "react-router";
|
||||
import { createReadableStreamFromReadable } from "@react-router/node";
|
||||
import { ServerRouter } from "react-router";
|
||||
import { isbot } from "isbot";
|
||||
import type { RenderToPipeableStreamOptions } from "react-dom/server";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
|
||||
const sentryHandleError = Sentry.createSentryHandleError({
|
||||
export const handleError = Sentry.createSentryHandleError({
|
||||
logErrors: true,
|
||||
});
|
||||
|
||||
export const handleError: HandleErrorFunction = (error, args) => {
|
||||
// Unrecognised URLs and methods are bot scans, not bugs. Skipping early also
|
||||
// keeps them out of the `logErrors` console output; morgan still logs the request.
|
||||
if (!shouldReportServerError(error, args.request)) return;
|
||||
return sentryHandleError(error, args);
|
||||
};
|
||||
|
||||
export const streamTimeout = 5_000;
|
||||
|
||||
async function handleRequest(
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
/**
|
||||
* The AFL Wildcard winners are re-seeded into the Elimination Finals by ladder position
|
||||
* (5th draws the lower-ranked winner, 6th the higher-ranked one) rather than crossing
|
||||
* over from a fixed Wildcard match. These tests pin that mapping for every combination
|
||||
* of results, and for either order of entry.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
resolveAflWildcardPlacements,
|
||||
AFL_WILDCARD_DRAW,
|
||||
AFL_ELIMINATION_HOSTS,
|
||||
type AflWildcardResult,
|
||||
} from "../afl-wildcard-reseed";
|
||||
|
||||
/** Both Wildcard games decided, addressed by the seed that won each. */
|
||||
function bothDecided(match1Winner: 7 | 10, match2Winner: 8 | 9): AflWildcardResult[] {
|
||||
return [
|
||||
{ matchNumber: 1, winnerSlot: match1Winner === 7 ? 1 : 2 },
|
||||
{ matchNumber: 2, winnerSlot: match2Winner === 8 ? 1 : 2 },
|
||||
];
|
||||
}
|
||||
|
||||
/** Elimination Finals match number each winning seed was sent to. */
|
||||
function slotsBySeed(results: AflWildcardResult[]): Record<number, number> {
|
||||
return Object.fromEntries(
|
||||
resolveAflWildcardPlacements(results).map((p) => [p.seed, p.eliminationMatchNumber])
|
||||
);
|
||||
}
|
||||
|
||||
describe("AFL Wildcard draw constants", () => {
|
||||
it("draws 7v10 and 8v9", () => {
|
||||
expect(AFL_WILDCARD_DRAW[1]).toEqual([7, 10]);
|
||||
expect(AFL_WILDCARD_DRAW[2]).toEqual([8, 9]);
|
||||
});
|
||||
|
||||
it("hosts the Elimination Finals with seeds 5 and 6", () => {
|
||||
expect(AFL_ELIMINATION_HOSTS[1]).toBe(5);
|
||||
expect(AFL_ELIMINATION_HOSTS[2]).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAflWildcardPlacements", () => {
|
||||
it("sends the higher-ranked winner to 6th and the lower to 5th (7 and 8 win)", () => {
|
||||
expect(slotsBySeed(bothDecided(7, 8))).toEqual({ 7: 2, 8: 1 });
|
||||
});
|
||||
|
||||
it("re-seeds when the lower seed wins the 7v10 game (10 and 8 win)", () => {
|
||||
// The bug this replaces sent the 7v10 winner to 6th regardless, pairing 5th with
|
||||
// 8th and handing 6th the weakest survivor.
|
||||
expect(slotsBySeed(bothDecided(10, 8))).toEqual({ 8: 2, 10: 1 });
|
||||
});
|
||||
|
||||
it("re-seeds when the lower seed wins the 8v9 game (7 and 9 win)", () => {
|
||||
expect(slotsBySeed(bothDecided(7, 9))).toEqual({ 7: 2, 9: 1 });
|
||||
});
|
||||
|
||||
it("re-seeds when both lower seeds win (10 and 9 win)", () => {
|
||||
expect(slotsBySeed(bothDecided(10, 9))).toEqual({ 9: 2, 10: 1 });
|
||||
});
|
||||
|
||||
it("places the 7v10 winner alone, since its rank is settled either way", () => {
|
||||
// 7th outranks both possible 8v9 winners; 10th is outranked by both.
|
||||
expect(slotsBySeed([
|
||||
{ matchNumber: 1, winnerSlot: 1 },
|
||||
{ matchNumber: 2, winnerSlot: null },
|
||||
])).toEqual({ 7: 2 });
|
||||
|
||||
expect(slotsBySeed([
|
||||
{ matchNumber: 1, winnerSlot: 2 },
|
||||
{ matchNumber: 2, winnerSlot: null },
|
||||
])).toEqual({ 10: 1 });
|
||||
});
|
||||
|
||||
it("holds an 8v9 winner back until the 7v10 game is decided", () => {
|
||||
// 8th and 9th both sit between 7th and 10th, so either slot is still possible.
|
||||
expect(slotsBySeed([
|
||||
{ matchNumber: 1, winnerSlot: null },
|
||||
{ matchNumber: 2, winnerSlot: 1 },
|
||||
])).toEqual({});
|
||||
|
||||
expect(slotsBySeed([
|
||||
{ matchNumber: 1, winnerSlot: null },
|
||||
{ matchNumber: 2, winnerSlot: 2 },
|
||||
])).toEqual({});
|
||||
});
|
||||
|
||||
it("places nothing while both games are undecided", () => {
|
||||
expect(resolveAflWildcardPlacements([
|
||||
{ matchNumber: 1, winnerSlot: null },
|
||||
{ matchNumber: 2, winnerSlot: null },
|
||||
])).toEqual([]);
|
||||
});
|
||||
|
||||
it("gives the same answer whichever result is entered first", () => {
|
||||
for (const m1 of [7, 10] as const) {
|
||||
for (const m2 of [8, 9] as const) {
|
||||
const final = slotsBySeed(bothDecided(m1, m2));
|
||||
|
||||
// Whatever a single result places must survive the second result unchanged.
|
||||
const m1First = slotsBySeed([
|
||||
{ matchNumber: 1, winnerSlot: m1 === 7 ? 1 : 2 },
|
||||
{ matchNumber: 2, winnerSlot: null },
|
||||
]);
|
||||
for (const [seed, slot] of Object.entries(m1First)) {
|
||||
expect(final[Number(seed)]).toBe(slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a match number outside the Wildcard draw", () => {
|
||||
expect(() => resolveAflWildcardPlacements([{ matchNumber: 3, winnerSlot: 1 }])).toThrow(
|
||||
/Unknown AFL Wildcard Round match number 3/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,533 +0,0 @@
|
|||
/**
|
||||
* Bracket layout tests.
|
||||
*
|
||||
* The load-bearing assertions check the LLWS geometry against the official 2026 LLBWS
|
||||
* bracket, in the PDF's own game numbers. A bracket "lines up" when each card sits level
|
||||
* with the game that feeds it, so these tests assert column membership, top-to-bottom
|
||||
* order, and vertical alignment — not just that a layout was produced.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
LLWS_20,
|
||||
SIMPLE_16,
|
||||
NFL_14,
|
||||
BRACKET_TEMPLATES,
|
||||
getBracketTemplate,
|
||||
type BracketTemplate,
|
||||
type ConferenceGroup,
|
||||
} from "~/lib/bracket-templates";
|
||||
import {
|
||||
buildFeederMap,
|
||||
computeGroupLayout,
|
||||
describeSlotSource,
|
||||
matchKey,
|
||||
type SlotSource,
|
||||
} from "~/lib/bracket-layout";
|
||||
import { GAME_TO_MATCH, MATCH_TO_GAME } from "~/test/fixtures/llws-bracket";
|
||||
|
||||
interface TestMatch {
|
||||
round: string;
|
||||
matchNumber: number;
|
||||
/** Only the fallback reads these, to trace edges through an unrecognised shape. */
|
||||
winnerId?: string | null;
|
||||
participant1Id?: string | null;
|
||||
participant2Id?: string | null;
|
||||
}
|
||||
|
||||
/** Every match a template defines, as the renderer would receive them. */
|
||||
function allMatches(template: BracketTemplate): Map<string, TestMatch[]> {
|
||||
const byRound = new Map<string, TestMatch[]>();
|
||||
for (const round of template.rounds) {
|
||||
byRound.set(
|
||||
round.name,
|
||||
Array.from({ length: round.matchCount }, (_, i) => ({
|
||||
round: round.name,
|
||||
matchNumber: i + 1,
|
||||
}))
|
||||
);
|
||||
}
|
||||
return byRound;
|
||||
}
|
||||
|
||||
/** The matches of one phase group, filtered the way TabbedBracketLayout filters them. */
|
||||
function groupMatches(group: ConferenceGroup): Map<string, TestMatch[]> {
|
||||
const byRound = new Map<string, TestMatch[]>();
|
||||
for (const [round, nums] of Object.entries(group.roundMatchNumbers)) {
|
||||
byRound.set(
|
||||
round,
|
||||
nums.map((matchNumber) => ({ round, matchNumber }))
|
||||
);
|
||||
}
|
||||
return byRound;
|
||||
}
|
||||
|
||||
function findGroup(name: string): ConferenceGroup {
|
||||
for (const phase of LLWS_20.phases ?? []) {
|
||||
for (const group of phase.groups ?? []) {
|
||||
if (group.name === name) return group;
|
||||
}
|
||||
}
|
||||
throw new Error(`No LLWS group named ${name}`);
|
||||
}
|
||||
|
||||
/** Lay out one LLWS group and describe it in PDF game numbers. */
|
||||
function layOutLLWSGroup(name: string) {
|
||||
const group = findGroup(name);
|
||||
const byRound = groupMatches(group);
|
||||
const roundOrder = LLWS_20.rounds.map((r) => r.name);
|
||||
const rounds = roundOrder.filter((r) => byRound.has(r));
|
||||
|
||||
const layout = computeGroupLayout(rounds, byRound, buildFeederMap(LLWS_20), roundOrder);
|
||||
|
||||
const game = (m: TestMatch) => {
|
||||
const n = MATCH_TO_GAME.get(`${m.round}#${m.matchNumber}`);
|
||||
if (n === undefined) throw new Error(`No PDF game for ${m.round} #${m.matchNumber}`);
|
||||
return n;
|
||||
};
|
||||
|
||||
return {
|
||||
layout,
|
||||
labels: layout.columns.map((c) => c.label),
|
||||
/** Column contents, top to bottom, as PDF game numbers. */
|
||||
columns: layout.columns.map((c) => c.matches.map((m) => game(m.match))),
|
||||
/** Vertical centre of a game's card, in leaf-row units. */
|
||||
centerOf(gameNumber: number): number {
|
||||
const target = GAME_TO_MATCH[gameNumber];
|
||||
for (const column of layout.columns) {
|
||||
for (const { match, center } of column.matches) {
|
||||
if (match.round === target.round && match.matchNumber === target.matchNumber) {
|
||||
return center;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`G${gameNumber} is not in this group`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("computeGroupLayout — LLWS winners brackets", () => {
|
||||
// The International side is the one in the reported screenshot. Under the old index
|
||||
// math, G5 and G7 were stranded in the first column: they skip Winners Round 2 and go
|
||||
// straight to the semifinals, so nothing in column two lined up with them.
|
||||
it("puts the International winners bracket in the printed bracket's columns", () => {
|
||||
const { columns } = layOutLLWSGroup("International Winner's Bracket");
|
||||
expect(columns).toEqual([
|
||||
[1, 3],
|
||||
[5, 9, 11, 7],
|
||||
[18, 20],
|
||||
[29],
|
||||
]);
|
||||
});
|
||||
|
||||
it("mirrors that layout on the U.S. side", () => {
|
||||
const { columns } = layOutLLWSGroup("U.S. Winner's Bracket");
|
||||
expect(columns).toEqual([
|
||||
[2, 4],
|
||||
[6, 10, 12, 8],
|
||||
[17, 19],
|
||||
[30],
|
||||
]);
|
||||
});
|
||||
|
||||
it("names a mixed column for the latest round it holds", () => {
|
||||
// Column two holds two Opening Round games (G5, G7) alongside Winners Round 2.
|
||||
const { labels } = layOutLLWSGroup("International Winner's Bracket");
|
||||
expect(labels).toEqual([
|
||||
"Opening Round",
|
||||
"Winners Round 2",
|
||||
"Winners Semifinals",
|
||||
"Winners Final",
|
||||
]);
|
||||
});
|
||||
|
||||
it("levels each card with the game that feeds it", () => {
|
||||
const { centerOf } = layOutLLWSGroup("International Winner's Bracket");
|
||||
|
||||
// G1's winner fills a slot of G9, so the two sit at the same height.
|
||||
expect(centerOf(1)).toBe(centerOf(9));
|
||||
expect(centerOf(3)).toBe(centerOf(11));
|
||||
|
||||
// G18 = W5 v W9, so it sits midway between them.
|
||||
expect(centerOf(18)).toBe((centerOf(5) + centerOf(9)) / 2);
|
||||
expect(centerOf(20)).toBe((centerOf(11) + centerOf(7)) / 2);
|
||||
expect(centerOf(29)).toBe((centerOf(18) + centerOf(20)) / 2);
|
||||
});
|
||||
|
||||
it("draws an edge for every in-group feed, played or not", () => {
|
||||
const { layout } = layOutLLWSGroup("International Winner's Bracket");
|
||||
// G9←G1, G11←G3, G18←{G5,G9}, G20←{G11,G7}, G29←{G18,G20}: 8 in-group edges.
|
||||
expect(layout.edges).toHaveLength(8);
|
||||
// Every edge crosses exactly one gutter, which is what makes them drawable.
|
||||
for (const edge of layout.edges) {
|
||||
expect(edge.fromColumn).toBeGreaterThanOrEqual(0);
|
||||
expect(edge.fromColumn).toBeLessThan(layout.columns.length - 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeGroupLayout — LLWS elimination brackets", () => {
|
||||
it("orders Elimination Round 3 the way the printed bracket does", () => {
|
||||
// G31 = W27 v W25, so the later game is printed on top — the reverse of match
|
||||
// number order, which is how the old index-based sort got it wrong.
|
||||
const { columns } = layOutLLWSGroup("International Elimination Bracket");
|
||||
expect(columns).toEqual([
|
||||
[13, 15],
|
||||
[21, 23],
|
||||
[27, 25],
|
||||
[31],
|
||||
[33],
|
||||
]);
|
||||
});
|
||||
|
||||
it("orders the U.S. elimination bracket the same way", () => {
|
||||
const { columns } = layOutLLWSGroup("U.S. Elimination Bracket");
|
||||
expect(columns).toEqual([
|
||||
[14, 16],
|
||||
[22, 24],
|
||||
[28, 26],
|
||||
[32],
|
||||
[34],
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores feeds arriving from the winners bracket", () => {
|
||||
// G21 = L9 v W13. L9 is in the winners bracket group, so only W13 is an edge here.
|
||||
const { layout, centerOf } = layOutLLWSGroup("International Elimination Bracket");
|
||||
expect(centerOf(21)).toBe(centerOf(13));
|
||||
expect(layout.edges).toHaveLength(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFeederMap", () => {
|
||||
it("routes LLWS winners and losers to the slots the printed bracket shows", () => {
|
||||
const feeders = buildFeederMap(LLWS_20);
|
||||
|
||||
// G18 = W5 v W9.
|
||||
const g18 = GAME_TO_MATCH[18];
|
||||
expect(feeders.get(matchKey(g18.round, g18.matchNumber))).toEqual([
|
||||
{ kind: "match", ref: GAME_TO_MATCH[5], result: "winner" },
|
||||
{ kind: "match", ref: GAME_TO_MATCH[9], result: "winner" },
|
||||
]);
|
||||
|
||||
// G13 = L3 v L5 — a winners-bracket loss drops into the elimination bracket.
|
||||
const g13 = GAME_TO_MATCH[13];
|
||||
expect(feeders.get(matchKey(g13.round, g13.matchNumber))).toEqual([
|
||||
{ kind: "match", ref: GAME_TO_MATCH[3], result: "loser" },
|
||||
{ kind: "match", ref: GAME_TO_MATCH[5], result: "loser" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks directly seeded slots as seeds", () => {
|
||||
const feeders = buildFeederMap(LLWS_20);
|
||||
// G9 = a bye team v W1: slot one is seeded, slot two is fed.
|
||||
const g9 = GAME_TO_MATCH[9];
|
||||
const [p1, p2] = feeders.get(matchKey(g9.round, g9.matchNumber)) ?? [];
|
||||
expect(p1).toEqual({ kind: "seed" });
|
||||
expect(p2).toEqual({ kind: "match", ref: GAME_TO_MATCH[1], result: "winner" });
|
||||
});
|
||||
|
||||
it("applies the standard halving rule to other templates", () => {
|
||||
const feeders = buildFeederMap(SIMPLE_16);
|
||||
expect(feeders.get(matchKey("Quarterfinals", 1))).toEqual([
|
||||
{ kind: "match", ref: { round: "Round of 16", matchNumber: 1 }, result: "winner" },
|
||||
{ kind: "match", ref: { round: "Round of 16", matchNumber: 2 }, result: "winner" },
|
||||
]);
|
||||
expect(feeders.get(matchKey("Quarterfinals", 4))).toEqual([
|
||||
{ kind: "match", ref: { round: "Round of 16", matchNumber: 7 }, result: "winner" },
|
||||
{ kind: "match", ref: { round: "Round of 16", matchNumber: 8 }, result: "winner" },
|
||||
]);
|
||||
// The first round is seeded, not fed.
|
||||
expect(feeders.get(matchKey("Round of 16", 1))).toEqual([
|
||||
{ kind: "seed" },
|
||||
{ kind: "seed" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty map without a template", () => {
|
||||
expect(buildFeederMap(undefined).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeGroupLayout — standard brackets are unchanged", () => {
|
||||
it("halves a 16-team bracket evenly, first round in seeded order", () => {
|
||||
const byRound = allMatches(SIMPLE_16);
|
||||
const roundOrder = SIMPLE_16.rounds.map((r) => r.name);
|
||||
const layout = computeGroupLayout(
|
||||
roundOrder,
|
||||
byRound,
|
||||
buildFeederMap(SIMPLE_16),
|
||||
roundOrder
|
||||
);
|
||||
|
||||
expect(layout.leafCount).toBe(8);
|
||||
expect(layout.columns.map((c) => c.label)).toEqual(roundOrder);
|
||||
expect(layout.columns.map((c) => c.matches.map((m) => m.match.matchNumber))).toEqual([
|
||||
[1, 2, 3, 4, 5, 6, 7, 8],
|
||||
[1, 2, 3, 4],
|
||||
[1, 2],
|
||||
[1],
|
||||
]);
|
||||
// Evenly spread, exactly as the previous index math placed them.
|
||||
expect(layout.columns[0].matches.map((m) => m.center)).toEqual([
|
||||
0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5,
|
||||
]);
|
||||
expect(layout.columns[3].matches[0].center).toBe(4);
|
||||
});
|
||||
|
||||
it("handles byes, placing a seeded team level with the round it enters", () => {
|
||||
// The NFL bracket's top seeds skip the wild card round.
|
||||
const byRound = allMatches(NFL_14);
|
||||
const roundOrder = NFL_14.rounds.map((r) => r.name);
|
||||
const layout = computeGroupLayout(roundOrder, byRound, buildFeederMap(NFL_14), roundOrder);
|
||||
expect(layout.columns.length).toBeGreaterThan(0);
|
||||
for (const column of layout.columns) {
|
||||
expect(column.matches.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to even spreading when a group has no single root", () => {
|
||||
// Two finals and no way to join them — the shape can't resolve to one tree.
|
||||
const byRound = new Map<string, TestMatch[]>([
|
||||
["Semifinals", [{ round: "Semifinals", matchNumber: 1 }]],
|
||||
[
|
||||
"Finals",
|
||||
[
|
||||
{ round: "Finals", matchNumber: 1 },
|
||||
{ round: "Finals", matchNumber: 2 },
|
||||
],
|
||||
],
|
||||
]);
|
||||
const layout = computeGroupLayout(
|
||||
["Semifinals", "Finals"],
|
||||
byRound,
|
||||
new Map(),
|
||||
["Semifinals", "Finals"]
|
||||
);
|
||||
expect(layout.columns.map((c) => c.label)).toEqual(["Semifinals", "Finals"]);
|
||||
expect(layout.edges).toEqual([]);
|
||||
expect(layout.columns[0].matches[0].center).toBe(1);
|
||||
expect(layout.columns[1].matches.map((m) => m.center)).toEqual([0.5, 1.5]);
|
||||
});
|
||||
|
||||
it("returns nothing for an empty group", () => {
|
||||
const layout = computeGroupLayout([], new Map(), new Map(), []);
|
||||
expect(layout).toEqual({ columns: [], leafCount: 0, edges: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFeederMap — templates with routing of their own", () => {
|
||||
// The halving rule describes advanceWinnerTemplate, not every bracket. Inventing it
|
||||
// where it doesn't hold draws confident, wrong connectors and mislabels slots, which
|
||||
// is worse than drawing nothing.
|
||||
|
||||
it("follows feedsInto rather than the order rounds are listed in", () => {
|
||||
// AFL's Wildcard Round feeds the Elimination Finals, skipping the round printed
|
||||
// next to it, so array order would fabricate the whole chain.
|
||||
const afl = BRACKET_TEMPLATES.afl_10;
|
||||
const feeders = buildFeederMap(afl);
|
||||
const fed = [...feeders.entries()].filter(([, pair]) =>
|
||||
pair.some((s) => s.kind === "match")
|
||||
);
|
||||
// Only Preliminary Finals → Grand Final actually halves.
|
||||
expect(fed.map(([key]) => key)).toEqual(["Grand Final#1"]);
|
||||
});
|
||||
|
||||
it("leaves a bye round's slots seeded rather than inventing feeds", () => {
|
||||
// CFP's First Round (4) feeds the Quarterfinals (4) — the top seeds have byes.
|
||||
const feeders = buildFeederMap(BRACKET_TEMPLATES.cfp_12);
|
||||
expect(feeders.get(matchKey("Quarterfinals", 1))).toEqual([
|
||||
{ kind: "seed" },
|
||||
{ kind: "seed" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves the First Four out of the Round of 64", () => {
|
||||
// advanceFirstFourWinner puts each winner in a specific seed slot, not games 1-2.
|
||||
const feeders = buildFeederMap(BRACKET_TEMPLATES.ncaa_68);
|
||||
expect(feeders.get(matchKey("Round of 64", 1))).toEqual([
|
||||
{ kind: "seed" },
|
||||
{ kind: "seed" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves the NBA play-in alone, where a loser feeds forward", () => {
|
||||
// Play-In Round 2 pairs the 7v8 loser with the 9v10 winner, so the round sizes
|
||||
// halve but the winners-only rule still doesn't describe it.
|
||||
const feeders = buildFeederMap(BRACKET_TEMPLATES.nba_20);
|
||||
expect(feeders.get(matchKey("Play-In Round 2", 1))).toEqual([
|
||||
{ kind: "seed" },
|
||||
{ kind: "seed" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not route the FIFA final through the third place game", () => {
|
||||
// Third Place Game sits between Semifinals and Finals in round order, so array
|
||||
// order made it the Finals' feeder and left the Finals' second slot empty.
|
||||
const feeders = buildFeederMap(BRACKET_TEMPLATES.fifa_48);
|
||||
expect(feeders.get(matchKey("Finals", 1))).toEqual([
|
||||
{ kind: "match", ref: { round: "Semifinals", matchNumber: 1 }, result: "winner" },
|
||||
{ kind: "match", ref: { round: "Semifinals", matchNumber: 2 }, result: "winner" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeGroupLayout — every template still draws connectors", () => {
|
||||
/** Lay a whole template out the way BracketTreeView would. */
|
||||
function layOut(template: BracketTemplate) {
|
||||
const byRound = allMatches(template);
|
||||
const order = template.rounds.map((r) => r.name);
|
||||
// BracketTreeView renders a third place game outside the tree.
|
||||
const rounds = order.filter((r) => r !== "Third Place Game");
|
||||
return computeGroupLayout(rounds, byRound, buildFeederMap(template), order);
|
||||
}
|
||||
|
||||
// A gutter joining a column to one exactly half its size is a plain bracket join and
|
||||
// must always be drawn. Where the sizes don't halve — a bye round, a play-in, the
|
||||
// First Four — the routing is bespoke and nothing is drawn until the games decide it,
|
||||
// which is what these brackets did before.
|
||||
it.each(Object.keys(BRACKET_TEMPLATES).filter((id) => id !== "llws_20"))(
|
||||
"%s draws every gutter that halves",
|
||||
(id) => {
|
||||
const layout = layOut(BRACKET_TEMPLATES[id]);
|
||||
const gutters = new Set(layout.edges.map((e) => e.fromColumn));
|
||||
let halvingGutters = 0;
|
||||
for (let ci = 0; ci < layout.columns.length - 1; ci++) {
|
||||
const from = layout.columns[ci].matches.length;
|
||||
const to = layout.columns[ci + 1].matches.length;
|
||||
if (from !== to * 2) continue;
|
||||
halvingGutters += 1;
|
||||
expect(gutters).toContain(ci);
|
||||
}
|
||||
// Every template has at least one, so a template that lost all its lines fails.
|
||||
expect(halvingGutters).toBeGreaterThan(0);
|
||||
}
|
||||
);
|
||||
|
||||
it("keeps the FIFA bracket a single tree once the third place game is set aside", () => {
|
||||
const layout = layOut(BRACKET_TEMPLATES.fifa_48);
|
||||
expect(layout.columns.map((c) => c.label)).toEqual([
|
||||
"Round of 32",
|
||||
"Round of 16",
|
||||
"Quarterfinals",
|
||||
"Semifinals",
|
||||
"Finals",
|
||||
]);
|
||||
expect(layout.edges).toHaveLength(30);
|
||||
});
|
||||
|
||||
// llws_20 is excluded above because both sides in one group is genuinely not a tree;
|
||||
// it renders per side, which the tests further up cover.
|
||||
});
|
||||
|
||||
describe("computeGroupLayout — fallback keeps the old connectors", () => {
|
||||
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
|
||||
const byRound = new Map<string, TestMatch[]>([
|
||||
["Quarterfinals", [1, 2, 3, 4].map((n) => ({ round: "Quarterfinals", matchNumber: n }))],
|
||||
["Semifinals", [1, 2].map((n) => ({ round: "Semifinals", matchNumber: n }))],
|
||||
["Finals", [{ round: "Finals", matchNumber: 1 }]],
|
||||
]);
|
||||
|
||||
it("infers halving edges when there is no feeder map at all", () => {
|
||||
// A bracket with no template id, which SportSeasonDisplay renders.
|
||||
const layout = computeGroupLayout(rounds, byRound, new Map(), rounds);
|
||||
expect(layout.edges).toHaveLength(6);
|
||||
// Quarterfinals 1 and 2 both join Semifinal 1.
|
||||
const intoFirstSemi = layout.edges.filter((e) => e.toCenter === 1);
|
||||
expect(intoFirstSemi.map((e) => e.fromCenter)).toEqual([0.5, 1.5]);
|
||||
});
|
||||
|
||||
it("traces played winners when the shape is not a halving", () => {
|
||||
const irregular = new Map<string, TestMatch[]>([
|
||||
[
|
||||
"Wildcard",
|
||||
[
|
||||
{ round: "Wildcard", matchNumber: 1, winnerId: "a" },
|
||||
{ round: "Wildcard", matchNumber: 2, winnerId: "b" },
|
||||
],
|
||||
],
|
||||
[
|
||||
"Semifinals",
|
||||
[
|
||||
{ round: "Semifinals", matchNumber: 1, participant1Id: "seeded", participant2Id: "b" },
|
||||
{ round: "Semifinals", matchNumber: 2, participant1Id: "seeded2", participant2Id: "a" },
|
||||
],
|
||||
],
|
||||
]);
|
||||
const layout = computeGroupLayout(
|
||||
["Wildcard", "Semifinals"],
|
||||
irregular,
|
||||
new Map(),
|
||||
["Wildcard", "Semifinals"]
|
||||
);
|
||||
// b won Wildcard 2 (centre 1.5) and plays Semifinal 1 (centre 0.5) — a crossing
|
||||
// edge that only the actual result can reveal.
|
||||
expect(layout.edges).toContainEqual({ fromColumn: 0, fromCenter: 1.5, toCenter: 0.5 });
|
||||
expect(layout.edges).toContainEqual({ fromColumn: 0, fromCenter: 0.5, toCenter: 1.5 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeSlotSource", () => {
|
||||
const feeders = buildFeederMap(LLWS_20);
|
||||
const sourcesFor = (game: number): [SlotSource, SlotSource] => {
|
||||
const m = GAME_TO_MATCH[game];
|
||||
const pair = feeders.get(matchKey(m.round, m.matchNumber));
|
||||
if (!pair) throw new Error(`No feeders for G${game}`);
|
||||
return pair;
|
||||
};
|
||||
|
||||
it("names a winner feed", () => {
|
||||
// G18 = W5 v W9; G5 is Opening Round match 3 on the International side.
|
||||
expect(describeSlotSource(sourcesFor(18)[0], LLWS_20)).toBe("Winner of Opening 3");
|
||||
});
|
||||
|
||||
it("names a loser feed, which is the one no line can show", () => {
|
||||
// G21 = L9 v W13; G9 is Winners Round 2 match 1 on the International side.
|
||||
expect(describeSlotSource(sourcesFor(21)[0], LLWS_20)).toBe("Loser of Winners R2 1");
|
||||
// G25 = L18 v W23; G18 is International semifinal 1.
|
||||
expect(describeSlotSource(sourcesFor(25)[0], LLWS_20)).toBe("Loser of Winners SF 1");
|
||||
});
|
||||
|
||||
it("uses side-local numbers, as the printed bracket does", () => {
|
||||
// G27 = L20 v W21. G20 is Winners Semifinals match 4 globally, but International
|
||||
// semifinal 2 — the number the printed bracket uses.
|
||||
expect(describeSlotSource(sourcesFor(27)[0], LLWS_20)).toBe("Loser of Winners SF 2");
|
||||
});
|
||||
|
||||
it("names the side where each side plays only one such game", () => {
|
||||
// G37 = L36 v L35: both feeds are Bracket Championship losers, one per side, so a
|
||||
// number would say nothing and the side is the only thing that tells them apart.
|
||||
const [p1, p2] = sourcesFor(37);
|
||||
expect(describeSlotSource(p1, LLWS_20)).toBe("Loser of U.S. Bracket Final");
|
||||
expect(describeSlotSource(p2, LLWS_20)).toBe("Loser of Intl Bracket Final");
|
||||
|
||||
// Same rule inside a side bracket: G34 = L30 v W32.
|
||||
const [elimP1, elimP2] = sourcesFor(34);
|
||||
expect(describeSlotSource(elimP1, LLWS_20)).toBe("Loser of U.S. Winners Final");
|
||||
expect(describeSlotSource(elimP2, LLWS_20)).toBe("Winner of U.S. Elim R4");
|
||||
});
|
||||
|
||||
it("drops both number and side for the shared final games", () => {
|
||||
// The two sides meet here, so there is only one of each game in the whole bracket.
|
||||
const wc = describeSlotSource(
|
||||
{ kind: "match", ref: GAME_TO_MATCH[38], result: "winner" },
|
||||
LLWS_20
|
||||
);
|
||||
expect(wc).toBe("Winner of World Championship");
|
||||
});
|
||||
|
||||
it("returns null for a seeded slot so the caller can render TBD", () => {
|
||||
expect(describeSlotSource({ kind: "seed" }, LLWS_20)).toBeNull();
|
||||
expect(describeSlotSource(undefined, LLWS_20)).toBeNull();
|
||||
});
|
||||
|
||||
it("uses plain round names for non-LLWS templates", () => {
|
||||
const template = getBracketTemplate("simple_16");
|
||||
const source: SlotSource = {
|
||||
kind: "match",
|
||||
ref: { round: "Quarterfinals", matchNumber: 3 },
|
||||
result: "winner",
|
||||
};
|
||||
expect(describeSlotSource(source, template)).toBe("Winner of Quarterfinals 3");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { localDateTimeToUtcIso, toEventSortKey, utcIsoToLocalDateTime } from "../date-utils";
|
||||
import { localDateTimeToUtcIso, toEventSortKey } from "../date-utils";
|
||||
|
||||
describe("localDateTimeToUtcIso", () => {
|
||||
it("returns null for empty string", () => {
|
||||
|
|
@ -118,46 +118,6 @@ describe("event date display — UTC midnight rollover bug", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("utcIsoToLocalDateTime", () => {
|
||||
it("returns empty string for empty string", () => {
|
||||
expect(utcIsoToLocalDateTime("")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for null", () => {
|
||||
expect(utcIsoToLocalDateTime(null)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for undefined", () => {
|
||||
expect(utcIsoToLocalDateTime(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for an invalid date string", () => {
|
||||
expect(utcIsoToLocalDateTime("not-a-date")).toBe("");
|
||||
});
|
||||
|
||||
it("produces a datetime-local formatted string", () => {
|
||||
expect(utcIsoToLocalDateTime("2026-06-17T19:00:00.000Z")).toMatch(
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a Date instance", () => {
|
||||
const result = utcIsoToLocalDateTime(new Date("2026-06-17T19:00:00.000Z"));
|
||||
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
|
||||
});
|
||||
|
||||
it("round-trips with localDateTimeToUtcIso to the same instant", () => {
|
||||
// utcIsoToLocalDateTime renders a UTC instant in local time; feeding that
|
||||
// back through localDateTimeToUtcIso (which interprets local time) must
|
||||
// yield the original instant, regardless of the runtime timezone.
|
||||
const utc = "2026-06-17T19:00:00.000Z";
|
||||
const local = utcIsoToLocalDateTime(utc);
|
||||
const backToUtc = localDateTimeToUtcIso(local);
|
||||
expect(backToUtc).not.toBeNull();
|
||||
expect(new Date(backToUtc ?? "").getTime()).toBe(new Date(utc).getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe("toEventSortKey", () => {
|
||||
it("prefers earliestGameTime over eventDate", () => {
|
||||
const key = toEventSortKey({ eventDate: "2026-03-01", earliestGameTime: "2026-03-17T10:45:00.000Z" });
|
||||
|
|
|
|||
|
|
@ -1,230 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { createStaticHandler } from "react-router";
|
||||
import { shouldReportServerError } from "../error-reporting";
|
||||
|
||||
const ORIGIN = "https://brackt.com";
|
||||
|
||||
/** Shaped like the ErrorResponse React Router hands to `handleError`. */
|
||||
function routeError(
|
||||
status: number,
|
||||
internal: boolean,
|
||||
statusText = "Not Found",
|
||||
) {
|
||||
return {
|
||||
status,
|
||||
statusText,
|
||||
internal,
|
||||
data: `Error: No route matches URL "/blog/wp/v2/posts/999999"`,
|
||||
};
|
||||
}
|
||||
|
||||
function request(path: string, referer?: string, method = "GET") {
|
||||
return new Request(`${ORIGIN}${path}`, {
|
||||
method,
|
||||
headers: referer ? { referer } : {},
|
||||
});
|
||||
}
|
||||
|
||||
describe("shouldReportServerError", () => {
|
||||
it("drops a router 404 for a scanner hitting a URL cold", () => {
|
||||
expect(
|
||||
shouldReportServerError(
|
||||
routeError(404, true),
|
||||
request("/blog/wp/v2/posts/999999"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("drops a router 404 linked from another site", () => {
|
||||
expect(
|
||||
shouldReportServerError(
|
||||
routeError(404, true),
|
||||
request("/blog/", "https://evil.example/"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a router 404 linked from one of our own pages", () => {
|
||||
expect(
|
||||
shouldReportServerError(
|
||||
routeError(404, true),
|
||||
request("/leagues/gone", `${ORIGIN}/leagues`),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("drops the 405 from a POST to a route with no action", () => {
|
||||
expect(
|
||||
shouldReportServerError(
|
||||
routeError(405, true, "Method Not Allowed"),
|
||||
request("/", undefined, "POST"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("reports a 404 the app threw deliberately", () => {
|
||||
expect(
|
||||
shouldReportServerError(
|
||||
routeError(404, false),
|
||||
request("/leagues/missing"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reports a 403 the app threw from an ownership check", () => {
|
||||
expect(
|
||||
shouldReportServerError(
|
||||
routeError(403, false, "Forbidden"),
|
||||
request("/admin/sports"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reports a router-internal 500", () => {
|
||||
expect(
|
||||
shouldReportServerError(
|
||||
routeError(500, true, "Internal Server Error"),
|
||||
request("/leagues"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reports a plain exception", () => {
|
||||
expect(
|
||||
shouldReportServerError(new Error("boom"), request("/leagues")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reports anything that is not a route error response", () => {
|
||||
expect(shouldReportServerError("just a string", request("/leagues"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(shouldReportServerError(null, request("/leagues"))).toBe(true);
|
||||
});
|
||||
|
||||
it("drops a router 404 whose referer header is not a URL", () => {
|
||||
expect(
|
||||
shouldReportServerError(
|
||||
routeError(404, true),
|
||||
request("/blog/", "not a url"),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The unit tests above use hand-written error objects. These drive real requests
|
||||
* through React Router so the suite fails if the shape it throws ever changes.
|
||||
*/
|
||||
describe("shouldReportServerError against real React Router errors", () => {
|
||||
const handler = createStaticHandler([
|
||||
{
|
||||
id: "root",
|
||||
path: "/",
|
||||
children: [{ id: "home", index: true, loader: () => null }],
|
||||
},
|
||||
]);
|
||||
|
||||
async function errorFor(req: Request) {
|
||||
const ctx = await handler.query(req);
|
||||
if (ctx instanceof Response) return null;
|
||||
return Object.values(ctx.errors ?? {})[0] ?? null;
|
||||
}
|
||||
|
||||
it('drops the 404 for an unmatched URL (No route matches URL "...")', async () => {
|
||||
const req = request("/blog/wp/v2/posts/999999");
|
||||
const error = await errorFor(req);
|
||||
expect(error).toMatchObject({ status: 404, internal: true });
|
||||
expect(shouldReportServerError(error, req)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports the same 404 when it came from a link on our own site", async () => {
|
||||
const req = request("/nope", `${ORIGIN}/leagues`);
|
||||
expect(shouldReportServerError(await errorFor(req), req)).toBe(true);
|
||||
});
|
||||
|
||||
it("drops the 405 from a POST to a route with no action", async () => {
|
||||
const req = request("/", undefined, "POST");
|
||||
const error = await errorFor(req);
|
||||
expect(error).toMatchObject({ status: 405, internal: true });
|
||||
expect(shouldReportServerError(error, req)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("static asset 404s", () => {
|
||||
it("drops a stale hashed bundle even with a same-host referer", () => {
|
||||
// Every deploy leaves clients requesting the previous build's assets.
|
||||
const req = request("/assets/index-OLDHASH.js", `${ORIGIN}/leagues`);
|
||||
expect(shouldReportServerError(routeError(404, true), req)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/assets/app-x1.css",
|
||||
"/fonts/inter.woff2",
|
||||
"/images/logo.png",
|
||||
"/favicon.ico",
|
||||
])("drops a 404 for %s", (path) => {
|
||||
expect(
|
||||
shouldReportServerError(
|
||||
routeError(404, true),
|
||||
request(path, `${ORIGIN}/`),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("still follows the referer rule for a non-asset path containing a dot", () => {
|
||||
expect(
|
||||
shouldReportServerError(
|
||||
routeError(404, true),
|
||||
request("/leagues/v1.2", `${ORIGIN}/leagues`),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldReportServerError(routeError(404, true), request("/leagues/v1.2")),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("React Router internal statuses that are not 404/405", () => {
|
||||
it("reports an internal 400 (route is missing a loader)", () => {
|
||||
expect(
|
||||
shouldReportServerError(
|
||||
routeError(400, true, "Bad Request"),
|
||||
request("/leagues"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reports an internal 403 (route does not match URL)", () => {
|
||||
expect(
|
||||
shouldReportServerError(
|
||||
routeError(403, true, "Forbidden"),
|
||||
request("/leagues"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("production shape: TLS terminated upstream", () => {
|
||||
it("reports a 404 linked from our own site when the proxy strips https", () => {
|
||||
// Express builds request.url from req.protocol, which is `http` inside the
|
||||
// container. Real browsers send an https referer. Comparing full origins
|
||||
// would never match, silencing every broken internal link.
|
||||
const req = new Request("http://brackt.com/leagues/gone", {
|
||||
headers: { referer: "https://brackt.com/leagues" },
|
||||
});
|
||||
expect(shouldReportServerError(routeError(404, true), req)).toBe(true);
|
||||
});
|
||||
|
||||
it("still drops a cold scanner hit under that same shape", () => {
|
||||
const req = new Request("http://brackt.com/blog/wp/v2/posts/999999");
|
||||
expect(shouldReportServerError(routeError(404, true), req)).toBe(false);
|
||||
});
|
||||
|
||||
it("still drops a 404 linked from another site under that same shape", () => {
|
||||
const req = new Request("http://brackt.com/nope", {
|
||||
headers: { referer: "https://evil.example/" },
|
||||
});
|
||||
expect(shouldReportServerError(routeError(404, true), req)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
FIFA_2026_R32_TEMPLATE,
|
||||
THIRD_PLACE_SLOTS,
|
||||
assignThirdPlaceSlots,
|
||||
type R32Slot,
|
||||
} from "../fifa-2026-bracket";
|
||||
|
||||
const GROUPS = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L"];
|
||||
|
||||
describe("FIFA_2026_R32_TEMPLATE", () => {
|
||||
it("has 16 matches with unique DB match numbers 1–16", () => {
|
||||
expect(FIFA_2026_R32_TEMPLATE).toHaveLength(16);
|
||||
const dbNums = FIFA_2026_R32_TEMPLATE.map((m) => m.dbMatchNumber).toSorted((a, b) => a - b);
|
||||
expect(dbNums).toEqual(Array.from({ length: 16 }, (_, i) => i + 1));
|
||||
});
|
||||
|
||||
it("maps onto the official FIFA match numbers 73–88", () => {
|
||||
const fifaNums = FIFA_2026_R32_TEMPLATE.map((m) => m.fifaMatchNumber).toSorted((a, b) => a - b);
|
||||
expect(fifaNums).toEqual(Array.from({ length: 16 }, (_, i) => i + 73));
|
||||
});
|
||||
|
||||
it("uses each group winner and runner-up exactly once across the 32 slots", () => {
|
||||
const winners: string[] = [];
|
||||
const runnersUp: string[] = [];
|
||||
for (const m of FIFA_2026_R32_TEMPLATE) {
|
||||
for (const slot of [m.slot1, m.slot2] as R32Slot[]) {
|
||||
if (slot.kind === "winner") winners.push(slot.group);
|
||||
if (slot.kind === "runnerUp") runnersUp.push(slot.group);
|
||||
}
|
||||
}
|
||||
expect(winners.toSorted()).toEqual(GROUPS);
|
||||
expect(runnersUp.toSorted()).toEqual(GROUPS);
|
||||
});
|
||||
|
||||
it("has exactly 8 third-place slots", () => {
|
||||
expect(THIRD_PLACE_SLOTS).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assignThirdPlaceSlots", () => {
|
||||
it("assigns all 8 qualifying thirds to distinct, eligible slots", () => {
|
||||
// Best-8 thirds come from groups A–H (a plausible qualifying set).
|
||||
const qualifying = ["A", "B", "C", "D", "E", "F", "G", "H"];
|
||||
const assignment = assignThirdPlaceSlots(qualifying);
|
||||
|
||||
expect(assignment.size).toBe(8);
|
||||
|
||||
// Every slot filled, each by an eligible & qualifying group, no group reused.
|
||||
const usedGroups = new Set<string>();
|
||||
for (const slot of THIRD_PLACE_SLOTS) {
|
||||
const group = assignment.get(slot.thirdSlotId) ?? "";
|
||||
expect(group).not.toBe("");
|
||||
expect(slot.eligibleGroups).toContain(group);
|
||||
expect(qualifying).toContain(group);
|
||||
expect(usedGroups.has(group)).toBe(false);
|
||||
usedGroups.add(group);
|
||||
}
|
||||
expect(usedGroups.size).toBe(8);
|
||||
});
|
||||
|
||||
it("produces a valid matching for every 8-of-12 qualifying combination", () => {
|
||||
// Exhaustively check all C(12,8) = 495 combinations resolve to a complete,
|
||||
// eligibility-respecting, conflict-free assignment.
|
||||
let combinations = 0;
|
||||
const combos = (start: number, chosen: string[]) => {
|
||||
if (chosen.length === 8) {
|
||||
combinations++;
|
||||
const assignment = assignThirdPlaceSlots(chosen);
|
||||
expect(assignment.size).toBe(8);
|
||||
const used = new Set(assignment.values());
|
||||
expect(used.size).toBe(8);
|
||||
for (const slot of THIRD_PLACE_SLOTS) {
|
||||
const group = assignment.get(slot.thirdSlotId);
|
||||
expect(slot.eligibleGroups).toContain(group);
|
||||
expect(chosen).toContain(group);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (let i = start; i < GROUPS.length; i++) {
|
||||
combos(i + 1, [...chosen, GROUPS[i]]);
|
||||
}
|
||||
};
|
||||
combos(0, []);
|
||||
expect(combinations).toBe(495);
|
||||
});
|
||||
|
||||
it("is deterministic for a given combination", () => {
|
||||
const qualifying = ["B", "D", "E", "F", "H", "I", "J", "L"];
|
||||
const a = assignThirdPlaceSlots(qualifying);
|
||||
const b = assignThirdPlaceSlots(qualifying.toReversed());
|
||||
expect([...a.entries()].toSorted()).toEqual([...b.entries()].toSorted());
|
||||
});
|
||||
|
||||
it("matches FIFA's exact Annex C allocation for option 1 (groups E–L qualify)", () => {
|
||||
// Published row 1: 1A vs 3E, 1B vs 3J, 1D vs 3I, 1E vs 3F,
|
||||
// 1G vs 3H, 1I vs 3G, 1K vs 3L, 1L vs 3K.
|
||||
// Mapped to DB third-slot ids (the match each winner plays in):
|
||||
const assignment = assignThirdPlaceSlots(["E", "F", "G", "H", "I", "J", "K", "L"]);
|
||||
expect(assignment.get(11)).toBe("E"); // winner A's match
|
||||
expect(assignment.get(15)).toBe("J"); // winner B's match
|
||||
expect(assignment.get(7)).toBe("I"); // winner D's match
|
||||
expect(assignment.get(1)).toBe("F"); // winner E's match
|
||||
expect(assignment.get(8)).toBe("H"); // winner G's match
|
||||
expect(assignment.get(2)).toBe("G"); // winner I's match
|
||||
expect(assignment.get(16)).toBe("L"); // winner K's match
|
||||
expect(assignment.get(12)).toBe("K"); // winner L's match
|
||||
});
|
||||
|
||||
it("template eligible-group lists exactly match the Annex C table", () => {
|
||||
// For every slot, collect the set of groups the published table ever assigns
|
||||
// to it across all 495 combinations, and assert it equals the hand-written
|
||||
// eligibleGroups list in the template.
|
||||
const observed = new Map<number, Set<string>>(
|
||||
THIRD_PLACE_SLOTS.map((s) => [s.thirdSlotId, new Set<string>()])
|
||||
);
|
||||
const combos = (start: number, chosen: string[]) => {
|
||||
if (chosen.length === 8) {
|
||||
const assignment = assignThirdPlaceSlots(chosen);
|
||||
for (const [slotId, group] of assignment) observed.get(slotId)?.add(group);
|
||||
return;
|
||||
}
|
||||
for (let i = start; i < GROUPS.length; i++) combos(i + 1, [...chosen, GROUPS[i]]);
|
||||
};
|
||||
combos(0, []);
|
||||
|
||||
for (const slot of THIRD_PLACE_SLOTS) {
|
||||
expect([...(observed.get(slot.thirdSlotId) ?? [])].toSorted()).toEqual(
|
||||
[...slot.eligibleGroups].toSorted()
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -14,11 +14,6 @@ describe("normalizeTeamName", () => {
|
|||
const name = "oklahoma city thunder";
|
||||
expect(normalizeTeamName(name)).toBe(name);
|
||||
});
|
||||
|
||||
it("folds accents so diacritics don't block a match", () => {
|
||||
expect(normalizeTeamName("Stéfanos Tsitsipás")).toBe("stefanos tsitsipas");
|
||||
expect(normalizeTeamName("Félix Auger-Aliassime")).toBe("felix auger-aliassime");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findMatchingTeamName", () => {
|
||||
|
|
@ -43,12 +38,6 @@ describe("findMatchingTeamName", () => {
|
|||
expect(findMatchingTeamName("Oklahoma City", participants)).toBe("Oklahoma City Thunder");
|
||||
});
|
||||
|
||||
it("matches across accents (API has them, our list doesn't)", () => {
|
||||
expect(findMatchingTeamName("Stéfanos Tsitsipás", ["Stefanos Tsitsipas"])).toBe(
|
||||
"Stefanos Tsitsipas",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for unmatched name", () => {
|
||||
expect(findMatchingTeamName("Phoenix Suns", participants)).toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
/**
|
||||
* AFL Wildcard Round → Elimination Finals re-seeding.
|
||||
*
|
||||
* The Wildcard Round is drawn 7 v 10 and 8 v 9, and its two winners fill the open slots
|
||||
* in the Elimination Finals opposite the 5th and 6th seeds. Those slots are NOT a fixed
|
||||
* crossover: the winners are re-seeded by ladder position, exactly as the classic final
|
||||
* eight pairs 5 v 8 and 6 v 7 — the higher seed of the two hosts meets the lower-ranked
|
||||
* winner. So 5th plays whichever winner finished further down the ladder and 6th plays
|
||||
* the other, whichever Wildcard game each came out of.
|
||||
*
|
||||
* Worked example: 10th beats 7th and 9th beats 8th. A fixed crossover would send the
|
||||
* 7v10 winner (10th) to 6th and the 8v9 winner (9th) to 5th — handing the higher host
|
||||
* the better opponent. Re-seeded, 5th plays 10th and 6th plays 9th.
|
||||
*/
|
||||
|
||||
/** Seeds drawn into each Wildcard Round match, in [participant1, participant2] order. */
|
||||
export const AFL_WILDCARD_DRAW: Readonly<Record<number, readonly [number, number]>> = {
|
||||
1: [7, 10],
|
||||
2: [8, 9],
|
||||
};
|
||||
|
||||
/** Seed hosting each Elimination Finals match (its participant1 slot). */
|
||||
export const AFL_ELIMINATION_HOSTS: Readonly<Record<number, number>> = {
|
||||
1: 5,
|
||||
2: 6,
|
||||
};
|
||||
|
||||
export interface AflWildcardResult {
|
||||
matchNumber: number;
|
||||
/** Slot the winner occupied, or null while the match is still to be played. */
|
||||
winnerSlot: 1 | 2 | null;
|
||||
}
|
||||
|
||||
export interface AflWildcardPlacement {
|
||||
wildcardMatchNumber: number;
|
||||
/** Seed of the Wildcard winner being placed. */
|
||||
seed: number;
|
||||
eliminationMatchNumber: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which Elimination Final each decided Wildcard winner belongs in.
|
||||
*
|
||||
* A winner is only placed once its destination is settled whichever way the other
|
||||
* Wildcard game falls, so results can be entered in either order:
|
||||
* - 7th winning match 1 outranks both possible match 2 winners → always meets 6th.
|
||||
* - 10th winning match 1 is outranked by both → always meets 5th.
|
||||
* - A match 2 winner (8th or 9th) sits between them, so it is held back until match 1
|
||||
* is decided rather than being placed and then moved.
|
||||
*
|
||||
* Undecided winners are simply omitted; the caller fills the slots it is handed and
|
||||
* leaves the rest TBD.
|
||||
*/
|
||||
export function resolveAflWildcardPlacements(
|
||||
results: readonly AflWildcardResult[]
|
||||
): AflWildcardPlacement[] {
|
||||
const entries = results.map((result) => {
|
||||
const draw = AFL_WILDCARD_DRAW[result.matchNumber];
|
||||
if (!draw) {
|
||||
throw new Error(`Unknown AFL Wildcard Round match number ${result.matchNumber}`);
|
||||
}
|
||||
return {
|
||||
matchNumber: result.matchNumber,
|
||||
seed: result.winnerSlot === null ? null : draw[result.winnerSlot - 1],
|
||||
// Every seed the match could still send through — one entry once it is decided.
|
||||
possibleSeeds: result.winnerSlot === null ? [...draw] : [draw[result.winnerSlot - 1]],
|
||||
};
|
||||
});
|
||||
|
||||
// Best-ranked winner takes the weakest host, so order the hosts worst seed first.
|
||||
const hostsWorstFirst = Object.keys(AFL_ELIMINATION_HOSTS)
|
||||
.map(Number)
|
||||
.toSorted((a, b) => AFL_ELIMINATION_HOSTS[b] - AFL_ELIMINATION_HOSTS[a]);
|
||||
|
||||
const placements: AflWildcardPlacement[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const seed = entry.seed;
|
||||
if (seed === null) continue;
|
||||
|
||||
const others = entries.filter((other) => other !== entry);
|
||||
const outranks = (other: (typeof entries)[number]) => other.possibleSeeds.every((s) => s < seed);
|
||||
const outrankedBy = (other: (typeof entries)[number]) => other.possibleSeeds.every((s) => s > seed);
|
||||
|
||||
// This winner's rank is only knowable while every other one sits wholly above or
|
||||
// wholly below it — an undecided game straddling this seed leaves it unplaceable.
|
||||
if (!others.every((other) => outranks(other) || outrankedBy(other))) continue;
|
||||
|
||||
const rank = others.filter(outranks).length;
|
||||
const eliminationMatchNumber = hostsWorstFirst[rank];
|
||||
if (eliminationMatchNumber === undefined) {
|
||||
throw new Error(`No Elimination Finals slot for AFL Wildcard winner ranked ${rank + 1}`);
|
||||
}
|
||||
|
||||
placements.push({ wildcardMatchNumber: entry.matchNumber, seed, eliminationMatchNumber });
|
||||
}
|
||||
|
||||
return placements;
|
||||
}
|
||||
|
|
@ -1,420 +0,0 @@
|
|||
/**
|
||||
* Bracket geometry, derived from the real feeder graph.
|
||||
*
|
||||
* The renderer used to place cards by index within a round — match i at
|
||||
* `i * (height / roundSize)` — and drew connectors assuming matches 2k and 2k+1 feed
|
||||
* match k. That holds only when each round is an exact halving of the previous one.
|
||||
*
|
||||
* The LLWS winners bracket is not a halving: two of the four Opening Round games skip
|
||||
* Winners Round 2 entirely and go straight to the semifinals (see LLWS_ADVANCEMENT).
|
||||
* Under index math those games get pulled to the bottom of column one with nothing
|
||||
* above them in column two, and the connectors confidently join the wrong pairs.
|
||||
*
|
||||
* So lay out from the graph instead:
|
||||
* column = depth from the group's final, counted backwards
|
||||
* vertical order = the parent's slot order (participant1 above participant2)
|
||||
* connectors = actual feeder edges
|
||||
*
|
||||
* Counting columns back from the final is what makes a printed bracket line up: a team
|
||||
* entering late sits in the column where it actually plays, not the column its round
|
||||
* name suggests. For the LLWS International side this reproduces the official bracket
|
||||
* exactly, including putting the Australia/Mexico game alongside Winners Round 2.
|
||||
*
|
||||
* Pure — no React, no DB — so the geometry can be asserted against the printed bracket
|
||||
* in tests.
|
||||
*/
|
||||
|
||||
import {
|
||||
llwsSideAndLocal,
|
||||
type BracketTemplate,
|
||||
} from "~/lib/bracket-templates";
|
||||
import { resolveLLWSAdvancement } from "~/lib/llws-bracket";
|
||||
|
||||
// ── Feeder graph ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MatchRef {
|
||||
round: string;
|
||||
matchNumber: number;
|
||||
}
|
||||
|
||||
/** What fills one participant slot of a match. */
|
||||
export type SlotSource =
|
||||
| { kind: "match"; ref: MatchRef; result: "winner" | "loser" }
|
||||
| { kind: "seed" };
|
||||
|
||||
/** Keyed by `${round}#${matchNumber}`; the pair is [participant1, participant2]. */
|
||||
export type FeederMap = Map<string, [SlotSource, SlotSource]>;
|
||||
|
||||
const SEED: SlotSource = { kind: "seed" };
|
||||
|
||||
/**
|
||||
* `template.id:roundName` for transitions routed by a dedicated advancement function
|
||||
* rather than advanceWinnerTemplate's ceil(n/2) rule, and whose round sizes happen to
|
||||
* halve so the check in buildFeederMap can't rule them out on shape alone.
|
||||
*
|
||||
* The NBA play-in is the case: Play-In Round 2 pairs the 7v8 *loser* with the 9v10
|
||||
* winner (advanceNBAPlayInWinner), which no winners-only halving describes.
|
||||
*/
|
||||
const BESPOKE_TRANSITIONS = new Set(["nba_20:Play-In Round 1"]);
|
||||
|
||||
export function matchKey(round: string, matchNumber: number): string {
|
||||
return `${round}#${matchNumber}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invert a template's advancement rules into "what fills each slot".
|
||||
*
|
||||
* `llws_20` has an explicit, hand-verified routing table with deliberate cross-overs, so
|
||||
* it is inverted from that. Everything else follows the standard rule: slot p1 of match N
|
||||
* is the winner of match 2N-1 in the previous round, slot p2 the winner of match 2N.
|
||||
*/
|
||||
export function buildFeederMap(template: BracketTemplate | undefined): FeederMap {
|
||||
const feeders: FeederMap = new Map();
|
||||
if (!template) return feeders;
|
||||
|
||||
const slots = (key: string): [SlotSource, SlotSource] => {
|
||||
let pair = feeders.get(key);
|
||||
if (!pair) {
|
||||
pair = [SEED, SEED];
|
||||
feeders.set(key, pair);
|
||||
}
|
||||
return pair;
|
||||
};
|
||||
|
||||
// Seed every match in the template so unfed slots read as directly seeded.
|
||||
for (const round of template.rounds) {
|
||||
for (let n = 1; n <= round.matchCount; n++) slots(matchKey(round.name, n));
|
||||
}
|
||||
|
||||
if (template.id === "llws_20") {
|
||||
for (const round of template.rounds) {
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const { winner, loser } = resolveLLWSAdvancement(round.name, n);
|
||||
const ref: MatchRef = { round: round.name, matchNumber: n };
|
||||
for (const [destination, result] of [
|
||||
[winner, "winner"],
|
||||
[loser, "loser"],
|
||||
] as const) {
|
||||
if (!destination) continue;
|
||||
const pair = slots(matchKey(destination.round, destination.matchNumber));
|
||||
pair[destination.slot === "participant1Id" ? 0 : 1] = { kind: "match", ref, result };
|
||||
}
|
||||
}
|
||||
}
|
||||
return feeders;
|
||||
}
|
||||
|
||||
// Follow each round's declared `feedsInto` rather than array order — AFL's Wildcard
|
||||
// Round feeds the Elimination Finals, skipping the round printed next to it.
|
||||
for (const prev of template.rounds) {
|
||||
if (!prev.feedsInto) continue;
|
||||
const round = template.rounds.find((r) => r.name === prev.feedsInto);
|
||||
if (!round) continue;
|
||||
|
||||
// advanceWinnerTemplate sends match n to ceil(n/2) in the next round, slot by
|
||||
// parity. That describes the bracket only where the round halves exactly; a
|
||||
// play-in, a bye round, or a First Four routes by rules of its own, and inventing
|
||||
// a halving there would draw connectors and slot labels that are simply wrong.
|
||||
// Leaving those edges out drops the group to computeGroupLayout's fallback, which
|
||||
// is the geometry these brackets already had.
|
||||
if (prev.matchCount !== round.matchCount * 2) continue;
|
||||
if (BESPOKE_TRANSITIONS.has(`${template.id}:${prev.name}`)) continue;
|
||||
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const pair = slots(matchKey(round.name, n));
|
||||
pair[0] = {
|
||||
kind: "match",
|
||||
ref: { round: prev.name, matchNumber: 2 * n - 1 },
|
||||
result: "winner",
|
||||
};
|
||||
pair[1] = {
|
||||
kind: "match",
|
||||
ref: { round: prev.name, matchNumber: 2 * n },
|
||||
result: "winner",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return feeders;
|
||||
}
|
||||
|
||||
// ── Slot labels ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Round names as they read inside a card, where there is room for about twenty
|
||||
* characters. Anything not listed keeps its full name.
|
||||
*/
|
||||
const SHORT_ROUND_NAMES: Record<string, string> = {
|
||||
"Opening Round": "Opening",
|
||||
"Winners Round 2": "Winners R2",
|
||||
"Winners Semifinals": "Winners SF",
|
||||
"Winners Final": "Winners Final",
|
||||
"Elimination Round 1": "Elim R1",
|
||||
"Elimination Round 2": "Elim R2",
|
||||
"Elimination Round 3": "Elim R3",
|
||||
"Elimination Round 4": "Elim R4",
|
||||
"Elimination Final": "Elim Final",
|
||||
"Bracket Championship": "Bracket Final",
|
||||
};
|
||||
|
||||
/**
|
||||
* How an empty slot should read: "Winner of Winners SF 2" rather than "TBD".
|
||||
*
|
||||
* Returns null for a directly seeded slot, which the caller renders as "TBD".
|
||||
*
|
||||
* The cross-bracket feeds matter most here — a winners-bracket loser dropping into the
|
||||
* elimination bracket is a real edge that no line can show, because the two sides render
|
||||
* as separate trees.
|
||||
*/
|
||||
export function describeSlotSource(
|
||||
source: SlotSource | undefined,
|
||||
template: BracketTemplate | undefined
|
||||
): string | null {
|
||||
if (!source || source.kind !== "match") return null;
|
||||
|
||||
const { round, matchNumber } = source.ref;
|
||||
const name = SHORT_ROUND_NAMES[round] ?? round;
|
||||
const verb = source.result === "winner" ? "Winner" : "Loser";
|
||||
const roundMatchCount = template?.rounds.find((r) => r.name === round)?.matchCount ?? 0;
|
||||
|
||||
if (template?.id !== "llws_20") {
|
||||
return `${verb} of ${name}${roundMatchCount <= 1 ? "" : ` ${matchNumber}`}`;
|
||||
}
|
||||
|
||||
// LLWS numbers matches globally across both sides, so semifinal 4 is International
|
||||
// semifinal 2. Name it the way the printed bracket does — by side-local number, or by
|
||||
// side where each side plays only one such game and the number would say nothing.
|
||||
const { side, localMatch } = llwsSideAndLocal(round, matchNumber);
|
||||
const isShared = round === "Consolation Third Place" || round === "World Championship";
|
||||
const perSideCount = isShared ? roundMatchCount : roundMatchCount / 2;
|
||||
|
||||
if (perSideCount > 1) return `${verb} of ${name} ${localMatch}`;
|
||||
if (isShared) return `${verb} of ${name}`;
|
||||
return `${verb} of ${side === 0 ? "U.S." : "Intl"} ${name}`;
|
||||
}
|
||||
|
||||
// ── Layout ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface LaidOutMatch<M> {
|
||||
match: M;
|
||||
/** Centre of the card, in slot units (1 unit = one leaf row). */
|
||||
center: number;
|
||||
}
|
||||
|
||||
export interface LayoutColumn<M> {
|
||||
label: string;
|
||||
matches: LaidOutMatch<M>[];
|
||||
}
|
||||
|
||||
export interface BracketLayout<M> {
|
||||
columns: LayoutColumn<M>[];
|
||||
/** Number of leaf rows; multiply by row height for the pixel height of the bracket. */
|
||||
leafCount: number;
|
||||
/** Edges to draw, as (column index of the source, source centre, target centre). */
|
||||
edges: { fromColumn: number; fromCenter: number; toCenter: number }[];
|
||||
}
|
||||
|
||||
interface PositionedMatch {
|
||||
round: string;
|
||||
matchNumber: number;
|
||||
/** Only read by the fallback, to trace edges through an unrecognised shape. */
|
||||
winnerId?: string | null;
|
||||
participant1Id?: string | null;
|
||||
participant2Id?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay out one rendered group — a winners bracket, an elimination bracket, a region.
|
||||
*
|
||||
* `matchesByRound` should already be filtered to the group; cross-group feeds are
|
||||
* dropped, matching the printed bracket, which labels those slots rather than drawing
|
||||
* lines to another tree.
|
||||
*
|
||||
* Falls back to the previous index-based geometry when the group has no single root
|
||||
* (disjoint or unrecognised shapes), so no existing template can regress to a blank
|
||||
* column.
|
||||
*/
|
||||
export function computeGroupLayout<M extends PositionedMatch>(
|
||||
visibleRounds: string[],
|
||||
matchesByRound: Map<string, M[]>,
|
||||
feeders: FeederMap,
|
||||
templateRoundOrder: string[]
|
||||
): BracketLayout<M> {
|
||||
const nodes = new Map<string, M>();
|
||||
const roundOf = new Map<string, string>();
|
||||
for (const round of visibleRounds) {
|
||||
for (const match of matchesByRound.get(round) ?? []) {
|
||||
const key = matchKey(match.round, match.matchNumber);
|
||||
nodes.set(key, match);
|
||||
roundOf.set(key, round);
|
||||
}
|
||||
}
|
||||
if (nodes.size === 0) return { columns: [], leafCount: 0, edges: [] };
|
||||
|
||||
// In-group children, in slot order. A slot fed from outside the group has no card
|
||||
// here, so it contributes no edge.
|
||||
const childrenOf = new Map<string, string[]>();
|
||||
const hasParent = new Set<string>();
|
||||
for (const key of nodes.keys()) {
|
||||
const pair = feeders.get(key);
|
||||
const kids: string[] = [];
|
||||
for (const source of pair ?? []) {
|
||||
if (source.kind !== "match") continue;
|
||||
const childKey = matchKey(source.ref.round, source.ref.matchNumber);
|
||||
if (!nodes.has(childKey) || kids.includes(childKey)) continue;
|
||||
kids.push(childKey);
|
||||
hasParent.add(childKey);
|
||||
}
|
||||
childrenOf.set(key, kids);
|
||||
}
|
||||
|
||||
const roots = [...nodes.keys()].filter((k) => !hasParent.has(k));
|
||||
if (roots.length !== 1) {
|
||||
return fallbackLayout(visibleRounds, matchesByRound);
|
||||
}
|
||||
const [root] = roots;
|
||||
|
||||
// Depth from the root, then flip so leaves are column 0 and the final is last.
|
||||
//
|
||||
// Take the longest path, not the first one found: in a double-elimination bracket a
|
||||
// match feeds two places (its winner forward, its loser into the elimination side), so
|
||||
// the graph is a DAG and a node can be reached at several depths. The longest path is
|
||||
// the one that leaves room for every game on the way.
|
||||
const depth = new Map<string, number>();
|
||||
const assignDepth = (key: string, d: number) => {
|
||||
const known = depth.get(key);
|
||||
if (known !== undefined && known >= d) return;
|
||||
depth.set(key, d);
|
||||
for (const child of childrenOf.get(key) ?? []) assignDepth(child, d + 1);
|
||||
};
|
||||
assignDepth(root, 0);
|
||||
if (depth.size !== nodes.size) {
|
||||
return fallbackLayout(visibleRounds, matchesByRound);
|
||||
}
|
||||
const maxDepth = Math.max(...depth.values());
|
||||
const columnOf = (key: string) => maxDepth - (depth.get(key) ?? 0);
|
||||
|
||||
// Vertical order comes from a depth-first walk in slot order: participant1's feeder
|
||||
// sits above participant2's. This is why the elimination bracket's later game ends up
|
||||
// on top, as the printed bracket has it.
|
||||
const center = new Map<string, number>();
|
||||
let leafCount = 0;
|
||||
const place = (key: string): number => {
|
||||
const already = center.get(key);
|
||||
if (already !== undefined) return already;
|
||||
const kids = childrenOf.get(key) ?? [];
|
||||
if (kids.length === 0) {
|
||||
const y = leafCount + 0.5;
|
||||
leafCount += 1;
|
||||
center.set(key, y);
|
||||
return y;
|
||||
}
|
||||
const kidCenters = kids.map(place);
|
||||
const y = kidCenters.reduce((sum, c) => sum + c, 0) / kidCenters.length;
|
||||
center.set(key, y);
|
||||
return y;
|
||||
};
|
||||
place(root);
|
||||
|
||||
const columns: LayoutColumn<M>[] = Array.from({ length: maxDepth + 1 }, () => ({
|
||||
label: "",
|
||||
matches: [],
|
||||
}));
|
||||
for (const [key, match] of nodes) {
|
||||
columns[columnOf(key)].matches.push({ match, center: center.get(key) ?? 0 });
|
||||
}
|
||||
for (const column of columns) {
|
||||
column.matches.sort((a, b) => a.center - b.center);
|
||||
}
|
||||
|
||||
// A column can mix rounds — the LLWS second column holds two Opening Round games
|
||||
// alongside Winners Round 2. Name it for the latest round it contains, which is how
|
||||
// the printed bracket labels that column.
|
||||
for (let ci = 0; ci < columns.length; ci++) {
|
||||
const rounds = columns[ci].matches.map((m) => m.match.round);
|
||||
columns[ci].label = rounds.reduce((latest, r) =>
|
||||
templateRoundOrder.indexOf(r) > templateRoundOrder.indexOf(latest) ? r : latest
|
||||
);
|
||||
}
|
||||
|
||||
// Connectors live in the single gutter between adjacent columns, so only edges that
|
||||
// span exactly one gutter can be drawn. In a tree every edge does; in the DAG case a
|
||||
// feed can reach further back, and a line that stopped short would be worse than none.
|
||||
const edges: BracketLayout<M>["edges"] = [];
|
||||
for (const [key] of nodes) {
|
||||
const toCenter = center.get(key) ?? 0;
|
||||
for (const child of childrenOf.get(key) ?? []) {
|
||||
const fromColumn = columnOf(child);
|
||||
if (fromColumn !== columnOf(key) - 1) continue;
|
||||
edges.push({ fromColumn, fromCenter: center.get(child) ?? 0, toCenter });
|
||||
}
|
||||
}
|
||||
|
||||
return { columns, leafCount, edges };
|
||||
}
|
||||
|
||||
/**
|
||||
* The previous behaviour, kept for groups whose shape can't be resolved into a single
|
||||
* tree: one column per round, matches spread evenly over it, and edges inferred from the
|
||||
* round sizes. Brackets with bespoke routing (AFL, CFP byes, a bracket with no template)
|
||||
* land here, so it has to keep drawing what they drew before rather than nothing.
|
||||
*/
|
||||
function fallbackLayout<M extends PositionedMatch>(
|
||||
visibleRounds: string[],
|
||||
matchesByRound: Map<string, M[]>
|
||||
): BracketLayout<M> {
|
||||
const leafCount = Math.max(
|
||||
...visibleRounds.map((r) => matchesByRound.get(r)?.length ?? 0),
|
||||
1
|
||||
);
|
||||
const centersFor = (matches: M[]) => {
|
||||
const span = leafCount / Math.max(matches.length, 1);
|
||||
return matches.map((_, i) => (i + 0.5) * span);
|
||||
};
|
||||
|
||||
const columns = visibleRounds.map((round) => {
|
||||
const matches = matchesByRound.get(round) ?? [];
|
||||
const centers = centersFor(matches);
|
||||
return {
|
||||
label: round,
|
||||
matches: matches.map((match, i) => ({ match, center: centers[i] })),
|
||||
};
|
||||
});
|
||||
|
||||
const edges: BracketLayout<M>["edges"] = [];
|
||||
for (let ci = 0; ci < columns.length - 1; ci++) {
|
||||
const from = columns[ci].matches;
|
||||
const to = columns[ci + 1].matches;
|
||||
|
||||
if (to.length === Math.ceil(from.length / 2) && from.length > 1) {
|
||||
// A halving: matches 2k and 2k+1 feed match k.
|
||||
for (let k = 0; k < to.length; k++) {
|
||||
for (const idx of [2 * k, 2 * k + 1]) {
|
||||
if (idx >= from.length) continue;
|
||||
edges.push({
|
||||
fromColumn: ci,
|
||||
fromCenter: from[idx].center,
|
||||
toCenter: to[k].center,
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Otherwise the only thing that can be known is where a winner actually went, so
|
||||
// nothing is drawn until the games are played.
|
||||
const winnerToCenter = new Map<string, number>();
|
||||
for (const { match, center } of from) {
|
||||
if (match.winnerId) winnerToCenter.set(match.winnerId, center);
|
||||
}
|
||||
for (const { match, center } of to) {
|
||||
for (const id of [match.participant1Id, match.participant2Id]) {
|
||||
const fromCenter = id ? winnerToCenter.get(id) : undefined;
|
||||
if (fromCenter === undefined) continue;
|
||||
edges.push({ fromColumn: ci, fromCenter, toCenter: center });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { columns, leafCount, edges };
|
||||
}
|
||||
|
|
@ -19,32 +19,6 @@ export interface BracketRound {
|
|||
* When set, the loser of each match in this round is placed into the target round.
|
||||
*/
|
||||
loserFeedsInto?: string | null;
|
||||
/**
|
||||
* Floor position banked by the WINNER of a *non-scoring* round.
|
||||
*
|
||||
* Omit for the default behavior: winners entering the first scoring round bank a
|
||||
* T5–T8 floor (position 5), everyone else banks nothing. Set an explicit number when
|
||||
* that default is wrong — in a double-elimination losers bracket a win can guarantee
|
||||
* a worse finish than 5th (llws_20 "Elimination Round 3" → 7). Set null to bank no
|
||||
* floor even though the next round scores.
|
||||
*
|
||||
* Has no effect on scoring rounds, which use RoundScoringConfig.winnerFloor instead.
|
||||
*/
|
||||
nonScoringWinnerFloor?: number | null;
|
||||
/**
|
||||
* Floor position every team is guaranteed simply by being *seeded into* this
|
||||
* round when the bracket is generated — before a single match is played.
|
||||
*
|
||||
* Omit (the default) for rounds where entering guarantees nothing: a team that
|
||||
* loses its first match earns 0. Set a number when the bracket structure locks
|
||||
* in a scoring tier on entry — e.g. afl_10's Qualifying Finals, where the loser
|
||||
* still gets a Semi-Final and so cannot finish worse than the 5th-6th tier.
|
||||
*
|
||||
* Only teams actually assigned to a match slot at generation receive this floor;
|
||||
* TBD slots filled later by advancing winners get their floor from the round they
|
||||
* won (nonScoringWinnerFloor / RoundScoringConfig.winnerFloor) instead.
|
||||
*/
|
||||
entryFloor?: number | null;
|
||||
}
|
||||
|
||||
export interface GroupStageConfig {
|
||||
|
|
@ -481,31 +455,6 @@ export const DARTS_128: BracketTemplate = {
|
|||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Tennis Grand Slam (128-player single-elimination draw)
|
||||
* R128 → R64 → R32 → Round of 16 → Quarterfinals → Semifinals → Final
|
||||
*
|
||||
* Qualifying-points sport: QP is derived from how far each player advances
|
||||
* (see TEMPLATE_ROUND_CONFIG["tennis_128"] in scoring-calculator.ts). Scoring
|
||||
* begins at the Round of 16 — R16 losers share placements 9–16; earlier rounds
|
||||
* award nothing. Seeding: top 32 fixed, remaining 96 randomly drawn.
|
||||
*/
|
||||
export const TENNIS_128: BracketTemplate = {
|
||||
id: "tennis_128",
|
||||
name: "Tennis Grand Slam (128 Players)",
|
||||
totalTeams: 128,
|
||||
scoringStartsAtRound: "Round of 16",
|
||||
rounds: [
|
||||
{ name: "Round of 128", matchCount: 64, feedsInto: "Round of 64", isScoring: false },
|
||||
{ name: "Round of 64", matchCount: 32, feedsInto: "Round of 32", isScoring: false },
|
||||
{ name: "Round of 32", matchCount: 16, feedsInto: "Round of 16", isScoring: false },
|
||||
{ name: "Round of 16", matchCount: 8, feedsInto: "Quarterfinals", isScoring: true }, // losers share 9th–16th
|
||||
{ name: "Quarterfinals", matchCount: 4, feedsInto: "Semifinals", isScoring: true }, // losers share 5th–8th
|
||||
{ name: "Semifinals", matchCount: 2, feedsInto: "Final", isScoring: true }, // losers share 3rd–4th
|
||||
{ name: "Final", matchCount: 1, feedsInto: null, isScoring: true }, // winner 1st, loser 2nd
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* NCAA March Madness (68 teams)
|
||||
* First Four (play-in) → Round of 64 → Round of 32 → Sweet Sixteen → Elite Eight → Final Four → Championship
|
||||
|
|
@ -703,8 +652,7 @@ export const NFL_14: BracketTemplate = {
|
|||
* - Wildcard Round: 7v10, 8v9 (losers eliminated with 0 points)
|
||||
* - Week 1 Finals:
|
||||
* - Qualifying Finals: 1v4, 2v3 (losers get second chance)
|
||||
* - Elimination Finals: the two Wildcard winners are re-seeded by ladder position, so
|
||||
* 5th hosts the lower-ranked winner and 6th the higher-ranked one (losers share 7th-8th)
|
||||
* - Elimination Finals: 5v8(wildcard winner), 6v7(wildcard winner) (losers share 7th-8th)
|
||||
* - Week 2: Semi-Finals (QF losers vs EF winners, losers share 5th-6th)
|
||||
* - Week 3: Preliminary Finals (QF winners vs SF winners, losers share 3rd-4th)
|
||||
* - Week 4: Grand Final (1st vs 2nd)
|
||||
|
|
@ -722,32 +670,18 @@ export const AFL_10: BracketTemplate = {
|
|||
matchCount: 2,
|
||||
feedsInto: "Elimination Finals",
|
||||
isScoring: false, // Losers get 0 points (9th-10th)
|
||||
// A Wildcard win only buys an Elimination Final; losing that is the 7th-8th
|
||||
// tier, so the winner banks 7 — not the generic "entering a scoring round
|
||||
// means top-8" default of 5, which would over-award them a 5th-6th floor.
|
||||
nonScoringWinnerFloor: 7,
|
||||
},
|
||||
{
|
||||
name: "Qualifying Finals",
|
||||
matchCount: 2,
|
||||
feedsInto: "Preliminary Finals", // Winners get bye
|
||||
isScoring: false, // Losers get second chance (go to Semi-Finals)
|
||||
// Seeds 1-4 have the double chance from the moment the bracket is drawn:
|
||||
// lose the QF, lose the Semi-Final, and you still finish in the 5th-6th tier.
|
||||
entryFloor: 5,
|
||||
// Winning the QF is a bye straight to a Preliminary Final; losing that is the
|
||||
// 3rd-4th tier, so the winner's floor is 3 rather than the generic default of 5.
|
||||
nonScoringWinnerFloor: 3,
|
||||
},
|
||||
{
|
||||
name: "Elimination Finals",
|
||||
matchCount: 2,
|
||||
feedsInto: "Semi-Finals",
|
||||
isScoring: true, // Losers share 7th-8th
|
||||
// Seeds 5-6 are seeded straight into this round, so the 7th-8th tier is
|
||||
// locked in for them at generation. (The other slot is a TBD Wildcard winner,
|
||||
// who banks the same floor by winning the Wildcard Round.)
|
||||
entryFloor: 7,
|
||||
},
|
||||
{
|
||||
name: "Semi-Finals",
|
||||
|
|
@ -975,258 +909,6 @@ export const NBA_20: BracketTemplate = {
|
|||
],
|
||||
};
|
||||
|
||||
// ── LLWS 20 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Side-local match numbers → global match numbers, per round shape. */
|
||||
const LLWS_OPENING_OFFSET = 4; // Opening Round: US M1–4, Intl M5–8
|
||||
const LLWS_PAIR_OFFSET = 2; // 4-match rounds: US M1–2, Intl M3–4
|
||||
const LLWS_SOLO_OFFSET = 1; // 2-match rounds: US M1, Intl M2
|
||||
|
||||
/** Rounds with 4 matches (2 per side). Opening Round has 8; the rest have 2. */
|
||||
export const LLWS_FOUR_MATCH_ROUNDS = new Set([
|
||||
"Winners Round 2",
|
||||
"Elimination Round 1",
|
||||
"Winners Semifinals",
|
||||
"Elimination Round 2",
|
||||
"Elimination Round 3",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns the global match number for a side-local match in an LLWS round.
|
||||
* side 0 = United States, side 1 = International.
|
||||
*/
|
||||
export function llwsMatchNumber(round: string, side: 0 | 1, localMatch: number): number {
|
||||
const offset =
|
||||
round === "Opening Round"
|
||||
? LLWS_OPENING_OFFSET
|
||||
: LLWS_FOUR_MATCH_ROUNDS.has(round)
|
||||
? LLWS_PAIR_OFFSET
|
||||
: LLWS_SOLO_OFFSET;
|
||||
return localMatch + side * offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of llwsMatchNumber: global match number → { side, localMatch }.
|
||||
*/
|
||||
export function llwsSideAndLocal(
|
||||
round: string,
|
||||
matchNumber: number
|
||||
): { side: 0 | 1; localMatch: number } {
|
||||
const offset =
|
||||
round === "Opening Round"
|
||||
? LLWS_OPENING_OFFSET
|
||||
: LLWS_FOUR_MATCH_ROUNDS.has(round)
|
||||
? LLWS_PAIR_OFFSET
|
||||
: LLWS_SOLO_OFFSET;
|
||||
const side: 0 | 1 = matchNumber > offset ? 1 : 0;
|
||||
return { side, localMatch: matchNumber - side * offset };
|
||||
}
|
||||
|
||||
/**
|
||||
* Little League Baseball World Series (20 teams, 2025+ double-elimination format)
|
||||
*
|
||||
* Two independent 10-team double-elimination brackets — United States and
|
||||
* International — each producing a side champion, then a World Championship game and
|
||||
* a Consolation Third Place game between the two side runners-up.
|
||||
*
|
||||
* Rounds are shared across both sides: U.S. matches take the low match numbers and
|
||||
* International the high ones (see llwsMatchNumber). The phases/groups config splits
|
||||
* them back apart for display.
|
||||
*
|
||||
* A loss in the winners bracket is NOT an elimination — it drops the team into the
|
||||
* elimination bracket at a specific slot (see advanceLLWSWinner in models/playoff-match).
|
||||
* A loss in the elimination bracket is final.
|
||||
*
|
||||
* There is deliberately NO "if necessary" game: the winners-bracket champion is out if
|
||||
* it loses the Bracket Championship, dropping to the Consolation game rather than
|
||||
* forcing a rematch. This is the official LLWS modified double-elimination format.
|
||||
*
|
||||
* Placement tiers (only 8 teams score — the field is exactly 8 when Elim R4 begins):
|
||||
* 1st / 2nd World Championship
|
||||
* 3rd / 4th Consolation Third Place (real game, so positions are distinct)
|
||||
* 5th / 6th Elimination Final losers
|
||||
* 7th / 8th Elimination Round 4 losers
|
||||
* 0 pts the 12 teams eliminated in Elimination Rounds 1–3
|
||||
*
|
||||
* Participant array layout (20 slots):
|
||||
* [0–7] U.S. Opening Round teams, two per game (M1..M4)
|
||||
* [8, 9] U.S. bye teams, entering Winners Round 2 M1 / M2 at participant1
|
||||
* [10–17] International Opening Round teams, two per game (M5..M8)
|
||||
* [18,19] International bye teams, entering Winners Round 2 M3 / M4 at participant1
|
||||
*/
|
||||
export const LLWS_20: BracketTemplate = {
|
||||
id: "llws_20",
|
||||
name: "Little League World Series (20 teams)",
|
||||
totalTeams: 20,
|
||||
scoringStartsAtRound: "Winners Final",
|
||||
// Ordered by the real schedule so non-phased views read chronologically.
|
||||
rounds: [
|
||||
{
|
||||
name: "Opening Round",
|
||||
matchCount: 8,
|
||||
feedsInto: "Winners Round 2",
|
||||
isScoring: false,
|
||||
loserFeedsInto: "Elimination Round 1",
|
||||
nonScoringWinnerFloor: null, // 16 teams still alive — nothing guaranteed
|
||||
},
|
||||
{
|
||||
name: "Winners Round 2",
|
||||
matchCount: 4,
|
||||
feedsInto: "Winners Semifinals",
|
||||
isScoring: false,
|
||||
loserFeedsInto: "Elimination Round 2",
|
||||
nonScoringWinnerFloor: null,
|
||||
},
|
||||
{
|
||||
name: "Elimination Round 1",
|
||||
matchCount: 4,
|
||||
feedsInto: "Elimination Round 2",
|
||||
isScoring: false, // losers finish 13th–16th
|
||||
nonScoringWinnerFloor: null,
|
||||
},
|
||||
{
|
||||
name: "Winners Semifinals",
|
||||
matchCount: 4,
|
||||
feedsInto: "Winners Final",
|
||||
isScoring: false,
|
||||
loserFeedsInto: "Elimination Round 3",
|
||||
// Reaching the Winners Final guarantees at worst 5th (lose it, then lose the
|
||||
// Elimination Final). Same value as the engine default, stated explicitly.
|
||||
nonScoringWinnerFloor: 5,
|
||||
},
|
||||
{
|
||||
name: "Elimination Round 2",
|
||||
matchCount: 4,
|
||||
feedsInto: "Elimination Round 3",
|
||||
isScoring: false, // losers finish 11th–12th
|
||||
nonScoringWinnerFloor: null,
|
||||
},
|
||||
{
|
||||
name: "Elimination Round 3",
|
||||
matchCount: 4,
|
||||
feedsInto: "Elimination Round 4",
|
||||
isScoring: false, // losers finish 9th–10th
|
||||
// Winners reach Elimination Round 4, where a loss is 7th — not 5th.
|
||||
nonScoringWinnerFloor: 7,
|
||||
},
|
||||
{
|
||||
name: "Winners Final",
|
||||
matchCount: 2,
|
||||
feedsInto: "Bracket Championship",
|
||||
isScoring: true, // loser drops to the Elimination Final (provisional 5th)
|
||||
loserFeedsInto: "Elimination Final",
|
||||
},
|
||||
{
|
||||
name: "Elimination Round 4",
|
||||
matchCount: 2,
|
||||
feedsInto: "Elimination Final",
|
||||
isScoring: true, // losers share 7th–8th
|
||||
},
|
||||
{
|
||||
name: "Elimination Final",
|
||||
matchCount: 2,
|
||||
feedsInto: "Bracket Championship",
|
||||
isScoring: true, // losers share 5th–6th
|
||||
},
|
||||
{
|
||||
name: "Bracket Championship",
|
||||
matchCount: 2,
|
||||
feedsInto: "World Championship",
|
||||
isScoring: true, // loser drops to the Consolation game (provisional 4th)
|
||||
loserFeedsInto: "Consolation Third Place",
|
||||
},
|
||||
{
|
||||
name: "Consolation Third Place",
|
||||
matchCount: 1,
|
||||
feedsInto: null,
|
||||
isScoring: true, // winner 3rd, loser 4th
|
||||
},
|
||||
{
|
||||
name: "World Championship",
|
||||
matchCount: 1,
|
||||
feedsInto: null,
|
||||
isScoring: true, // winner 1st, loser 2nd
|
||||
},
|
||||
],
|
||||
// Region assignments rotate year to year (which region draws the bye changes), so
|
||||
// these are positional slot labels rather than region names. Kept short — the admin
|
||||
// form renders them in a narrow fixed-width column alongside each participant picker.
|
||||
participantLabels: [
|
||||
"US G1 Home", "US G1 Away",
|
||||
"US G2 Home", "US G2 Away",
|
||||
"US G3 Home", "US G3 Away",
|
||||
"US G4 Home", "US G4 Away",
|
||||
"US Bye 1", "US Bye 2",
|
||||
"Intl G1 Home", "Intl G1 Away",
|
||||
"Intl G2 Home", "Intl G2 Away",
|
||||
"Intl G3 Home", "Intl G3 Away",
|
||||
"Intl G4 Home", "Intl G4 Away",
|
||||
"Intl Bye 1", "Intl Bye 2",
|
||||
],
|
||||
phases: [
|
||||
{
|
||||
name: "United States",
|
||||
groups: [
|
||||
{
|
||||
name: "U.S. Winner's Bracket",
|
||||
roundMatchNumbers: {
|
||||
"Opening Round": [1, 2, 3, 4],
|
||||
"Winners Round 2": [1, 2],
|
||||
"Winners Semifinals": [1, 2],
|
||||
"Winners Final": [1],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "U.S. Elimination Bracket",
|
||||
roundMatchNumbers: {
|
||||
"Elimination Round 1": [1, 2],
|
||||
"Elimination Round 2": [1, 2],
|
||||
"Elimination Round 3": [1, 2],
|
||||
"Elimination Round 4": [1],
|
||||
"Elimination Final": [1],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "U.S. Championship",
|
||||
roundMatchNumbers: { "Bracket Championship": [1] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "International",
|
||||
groups: [
|
||||
{
|
||||
name: "International Winner's Bracket",
|
||||
roundMatchNumbers: {
|
||||
"Opening Round": [5, 6, 7, 8],
|
||||
"Winners Round 2": [3, 4],
|
||||
"Winners Semifinals": [3, 4],
|
||||
"Winners Final": [2],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "International Elimination Bracket",
|
||||
roundMatchNumbers: {
|
||||
"Elimination Round 1": [3, 4],
|
||||
"Elimination Round 2": [3, 4],
|
||||
"Elimination Round 3": [3, 4],
|
||||
"Elimination Round 4": [2],
|
||||
"Elimination Final": [2],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "International Championship",
|
||||
roundMatchNumbers: { "Bracket Championship": [2] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Championship",
|
||||
rounds: ["Consolation Third Place", "World Championship"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* All available bracket templates
|
||||
*/
|
||||
|
|
@ -1241,10 +923,8 @@ export const BRACKET_TEMPLATES: Record<string, BracketTemplate> = {
|
|||
afl_10: AFL_10,
|
||||
fifa_48: FIFA_48,
|
||||
darts_128: DARTS_128,
|
||||
tennis_128: TENNIS_128,
|
||||
cfp_12: CFP_12,
|
||||
nba_20: NBA_20,
|
||||
llws_20: LLWS_20,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -1300,18 +980,6 @@ export function getScoringRoundType(
|
|||
const round = template.rounds.find((r) => r.name === roundName);
|
||||
if (!round || !round.isScoring) return null;
|
||||
|
||||
// Special handling for LLWS double elimination: match counts don't identify the
|
||||
// tier (Elimination Round 4 and the Elimination Final both have 2 matches), and
|
||||
// the Winners Final eliminates nobody.
|
||||
if (template.id === "llws_20") {
|
||||
if (roundName === "Elimination Round 4") return "quarterfinals"; // losers share 7-8th
|
||||
if (roundName === "Elimination Final") return "quarterfinals"; // losers share 5-6th
|
||||
if (roundName === "Bracket Championship") return "semifinals"; // losers play for 3-4th
|
||||
if (roundName === "Consolation Third Place") return "semifinals"; // finalizes 3rd/4th
|
||||
if (roundName === "World Championship") return "finals"; // 1st and 2nd
|
||||
return null; // Winners Final: loser drops to the elimination bracket, nobody is out
|
||||
}
|
||||
|
||||
// Special handling for AFL finals
|
||||
if (template.id === "afl_10") {
|
||||
if (roundName === "Elimination Finals") return "quarterfinals"; // Losers share 7-8th
|
||||
|
|
|
|||
|
|
@ -18,8 +18,6 @@
|
|||
*/
|
||||
import { format } from "date-fns";
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
/**
|
||||
* Format a YYYY-MM-DD date string for display (e.g. "Apr 9").
|
||||
* Returns null when the value is missing or unparseable — callers decide how to handle that case.
|
||||
|
|
@ -59,28 +57,3 @@ export function localDateTimeToUtcIso(value: string | null | undefined): string
|
|||
if (isNaN(date.getTime())) return null;
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a stored UTC value (ISO string or Date) to the "YYYY-MM-DDTHH:MM"
|
||||
* string a `datetime-local` input expects, expressed in the **browser's local**
|
||||
* timezone. This is the inverse of `localDateTimeToUtcIso`.
|
||||
*
|
||||
* Built from local date getters (not `toISOString`, which is UTC) so the input
|
||||
* shows the viewer's wall-clock time. Because it depends on the runtime
|
||||
* timezone, call it on the client (e.g. inside `useEffect`) to avoid SSR
|
||||
* hydration mismatches.
|
||||
*
|
||||
* Returns "" for nullish or unparseable input so it can be assigned directly to
|
||||
* an input value.
|
||||
*/
|
||||
export function utcIsoToLocalDateTime(value: string | Date | null | undefined): string {
|
||||
if (!value) return "";
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (isNaN(date.getTime())) return "";
|
||||
const year = date.getFullYear();
|
||||
const month = pad(date.getMonth() + 1);
|
||||
const day = pad(date.getDate());
|
||||
const hours = pad(date.getHours());
|
||||
const minutes = pad(date.getMinutes());
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,87 +0,0 @@
|
|||
/**
|
||||
* Decides which server-side errors are worth sending to Sentry.
|
||||
*
|
||||
* Automated scanners probe for CMS paths that have never existed here
|
||||
* (`/blog/wp/v2/posts/999999`, `/wp-login.php`, a bare `POST /`). React Router
|
||||
* throws for each one — a 404 when no route matches, a 405 when a route has no
|
||||
* `action` — and every throw reaches `handleError` in `app/entry.server.tsx`.
|
||||
* Reporting those burns the Sentry quota without ever describing a real bug.
|
||||
*/
|
||||
import { isRouteErrorResponse } from "react-router";
|
||||
|
||||
/**
|
||||
* Statuses React Router uses to say "nothing here matched this request":
|
||||
* 404 when no route matches the URL, 405 when the route has no `action` or the
|
||||
* method is invalid. Its other internal statuses (400 "did not provide a
|
||||
* `loader`", 403 "Route does not match URL") describe a misconfigured route
|
||||
* rather than an unrecognised request, so those keep reporting.
|
||||
*/
|
||||
const UNMATCHED_REQUEST_STATUSES = new Set([404, 405]);
|
||||
|
||||
/**
|
||||
* Static assets 404 in bulk for reasons that are never actionable: scanners
|
||||
* guessing filenames, and clients running stale HTML that still references the
|
||||
* previous deploy's hashed bundles.
|
||||
*/
|
||||
const ASSET_EXT_RE =
|
||||
/\.(css|js|mjs|map|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|eot)$/i;
|
||||
|
||||
/** React Router stamps `internal: true` on the errors it generates itself. */
|
||||
function isInternalRouterError(error: unknown): boolean {
|
||||
return (error as { internal?: unknown }).internal === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the request was linked from a page on this same site.
|
||||
*
|
||||
* Compares host rather than origin on purpose. Production terminates TLS
|
||||
* upstream and serves plain HTTP in the container, so `request.url` — which
|
||||
* `@react-router/express` builds from `req.protocol` — says `http` while the
|
||||
* browser sends an `https` referer. Comparing full origins would therefore
|
||||
* never match in production. (`app/routes/leagues/$leagueId.server.ts` works
|
||||
* around the same mismatch for invite URLs.) Protocol tells us nothing about
|
||||
* whether the link was ours; host does.
|
||||
*/
|
||||
function hasSameHostReferer(request: Request): boolean {
|
||||
const referer = request.headers.get("referer");
|
||||
if (!referer) return false;
|
||||
try {
|
||||
return new URL(referer).host === new URL(request.url).host;
|
||||
} catch {
|
||||
// Scanners send garbage in this header; a referer we can't parse isn't ours.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `error` should be reported to Sentry.
|
||||
*
|
||||
* Drops the 404s and 405s React Router generated for a request that matched
|
||||
* nothing. Everything else is reported: real exceptions, 5xx, React Router's
|
||||
* other internal statuses, and responses the app threw deliberately
|
||||
* (`internal: false`), so a 403 from an ownership check still shows up.
|
||||
*
|
||||
* The exception is a request carrying a same-host `Referer`: a 404 reached from
|
||||
* one of our own pages is a broken internal link, not a scanner, and stays
|
||||
* visible in Sentry. Asset paths are excluded from that exception — a stale
|
||||
* client requesting last deploy's bundle sends a same-host referer too, and
|
||||
* would otherwise spike Sentry on every release.
|
||||
*/
|
||||
export function shouldReportServerError(
|
||||
error: unknown,
|
||||
request: Request,
|
||||
): boolean {
|
||||
if (!isRouteErrorResponse(error)) return true;
|
||||
if (!isInternalRouterError(error)) return true;
|
||||
if (!UNMATCHED_REQUEST_STATUSES.has(error.status)) return true;
|
||||
|
||||
let pathname: string;
|
||||
try {
|
||||
pathname = new URL(request.url).pathname;
|
||||
} catch {
|
||||
pathname = "";
|
||||
}
|
||||
if (ASSET_EXT_RE.test(pathname)) return false;
|
||||
|
||||
return hasSameHostReferer(request);
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
/**
|
||||
* Official 2026 FIFA World Cup knockout bracket structure (48-team format).
|
||||
*
|
||||
* Encodes how the 32 knockout qualifiers are seeded into the Round of 32 from
|
||||
* their group-stage finishing positions, so the simulator can reproduce the
|
||||
* real tournament draw *before* the admin has manually populated the bracket
|
||||
* (i.e. while the group stage is still being played).
|
||||
*
|
||||
* Two pieces:
|
||||
* (a) FIFA_2026_R32_TEMPLATE — the fixed group-position → slot mapping.
|
||||
* (b) assignThirdPlaceSlots() — which group's 3rd-place team fills each of the
|
||||
* eight "best third-place" slots, given which 8 of the 12 groups produced
|
||||
* a qualifying third.
|
||||
*
|
||||
* Bracket-tree note: the official FIFA match numbers (73–88 for the R32) do NOT
|
||||
* pair adjacently into the Round of 16 (e.g. R16 match 89 = winner of FIFA 74 vs
|
||||
* winner of FIFA 77). The codebase advances brackets with the canonical
|
||||
* `nextMatchNumber = ceil(matchNumber / 2)` rule (see `advanceWinnerTemplate` in
|
||||
* app/models/playoff-match.ts). To make that rule reproduce FIFA's real bracket
|
||||
* halves all the way to the final, the official matches are assigned to DB
|
||||
* matchNumbers 1–16 in the deliberate order below — verified through
|
||||
* R16 → QF → SF so both halves and the semifinal pairings match the real draw.
|
||||
*
|
||||
* Source: Wikipedia "2026 FIFA World Cup knockout stage" and
|
||||
* "Template:2026 FIFA World Cup third-place table" (FIFA regulations Annex C).
|
||||
*/
|
||||
|
||||
import {
|
||||
ANNEX_C_THIRD_PLACE_ALLOCATION,
|
||||
THIRD_PLACE_WINNER_COLUMNS,
|
||||
} from "./fifa-2026-third-place-allocation";
|
||||
|
||||
export type R32Slot =
|
||||
| { kind: "winner"; group: string }
|
||||
| { kind: "runnerUp"; group: string }
|
||||
/** thirdSlotId is the DB matchNumber this slot belongs to (each is unique). */
|
||||
| { kind: "third"; thirdSlotId: number; eligibleGroups: string[] };
|
||||
|
||||
export interface R32MatchSpec {
|
||||
/** 1–16; drives the codebase's ceil(n/2) bracket tree. */
|
||||
dbMatchNumber: number;
|
||||
/** Official FIFA match number (73–88); for traceability only. */
|
||||
fifaMatchNumber: number;
|
||||
slot1: R32Slot;
|
||||
slot2: R32Slot;
|
||||
}
|
||||
|
||||
const winner = (group: string): R32Slot => ({ kind: "winner", group });
|
||||
const runnerUp = (group: string): R32Slot => ({ kind: "runnerUp", group });
|
||||
const third = (dbMatchNumber: number, eligibleGroups: string[]): R32Slot => ({
|
||||
kind: "third",
|
||||
thirdSlotId: dbMatchNumber,
|
||||
eligibleGroups,
|
||||
});
|
||||
|
||||
export const FIFA_2026_R32_TEMPLATE: R32MatchSpec[] = [
|
||||
{ dbMatchNumber: 1, fifaMatchNumber: 74, slot1: winner("E"), slot2: third(1, ["A", "B", "C", "D", "F"]) },
|
||||
{ dbMatchNumber: 2, fifaMatchNumber: 77, slot1: winner("I"), slot2: third(2, ["C", "D", "F", "G", "H"]) },
|
||||
{ dbMatchNumber: 3, fifaMatchNumber: 73, slot1: runnerUp("A"), slot2: runnerUp("B") },
|
||||
{ dbMatchNumber: 4, fifaMatchNumber: 75, slot1: winner("F"), slot2: runnerUp("C") },
|
||||
{ dbMatchNumber: 5, fifaMatchNumber: 83, slot1: runnerUp("K"), slot2: runnerUp("L") },
|
||||
{ dbMatchNumber: 6, fifaMatchNumber: 84, slot1: winner("H"), slot2: runnerUp("J") },
|
||||
{ dbMatchNumber: 7, fifaMatchNumber: 81, slot1: winner("D"), slot2: third(7, ["B", "E", "F", "I", "J"]) },
|
||||
{ dbMatchNumber: 8, fifaMatchNumber: 82, slot1: winner("G"), slot2: third(8, ["A", "E", "H", "I", "J"]) },
|
||||
{ dbMatchNumber: 9, fifaMatchNumber: 76, slot1: winner("C"), slot2: runnerUp("F") },
|
||||
{ dbMatchNumber: 10, fifaMatchNumber: 78, slot1: runnerUp("E"), slot2: runnerUp("I") },
|
||||
{ dbMatchNumber: 11, fifaMatchNumber: 79, slot1: winner("A"), slot2: third(11, ["C", "E", "F", "H", "I"]) },
|
||||
{ dbMatchNumber: 12, fifaMatchNumber: 80, slot1: winner("L"), slot2: third(12, ["E", "H", "I", "J", "K"]) },
|
||||
{ dbMatchNumber: 13, fifaMatchNumber: 86, slot1: winner("J"), slot2: runnerUp("H") },
|
||||
{ dbMatchNumber: 14, fifaMatchNumber: 88, slot1: runnerUp("D"), slot2: runnerUp("G") },
|
||||
{ dbMatchNumber: 15, fifaMatchNumber: 85, slot1: winner("B"), slot2: third(15, ["E", "F", "G", "I", "J"]) },
|
||||
{ dbMatchNumber: 16, fifaMatchNumber: 87, slot1: winner("K"), slot2: third(16, ["D", "E", "I", "J", "L"]) },
|
||||
];
|
||||
|
||||
/** The eight third-place slots (DB matchNumber → eligible source groups). */
|
||||
export const THIRD_PLACE_SLOTS: Array<{ thirdSlotId: number; eligibleGroups: string[] }> =
|
||||
FIFA_2026_R32_TEMPLATE.flatMap((m) =>
|
||||
[m.slot1, m.slot2].filter((s): s is Extract<R32Slot, { kind: "third" }> => s.kind === "third")
|
||||
).map((s) => ({ thirdSlotId: s.thirdSlotId, eligibleGroups: s.eligibleGroups }));
|
||||
|
||||
/**
|
||||
* For each third-place slot, the group winner it faces — derived from the
|
||||
* template (a third slot always shares its match with a winner slot). This is
|
||||
* how the Annex C columns (keyed by group winner) map onto our DB slots.
|
||||
*/
|
||||
const WINNER_BY_THIRD_SLOT = new Map<number, string>(
|
||||
FIFA_2026_R32_TEMPLATE.flatMap((m) => {
|
||||
const slots = [m.slot1, m.slot2];
|
||||
const thirdSlot = slots.find((s) => s.kind === "third");
|
||||
const winnerSlot = slots.find((s) => s.kind === "winner");
|
||||
return thirdSlot && winnerSlot
|
||||
? [[thirdSlot.thirdSlotId, winnerSlot.group] as [number, string]]
|
||||
: [];
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Assign each qualifying third-place group to its third-place slot, using FIFA's
|
||||
* exact published Annex C allocation (495-row lookup). The lookup value lists the
|
||||
* third-place group facing each group winner in the column order
|
||||
* [1A, 1B, 1D, 1E, 1G, 1I, 1K, 1L]; we map each onto the slot that faces that
|
||||
* winner.
|
||||
*
|
||||
* Returns a map of thirdSlotId (DB matchNumber) → group letter. Falls back to a
|
||||
* constrained matching only for non-standard inputs (e.g. fewer than 8 thirds in
|
||||
* degraded data) where no Annex C row exists.
|
||||
*/
|
||||
export function assignThirdPlaceSlots(qualifyingGroups: string[]): Map<number, string> {
|
||||
const groups = qualifyingGroups.map((g) => g.toUpperCase());
|
||||
const key = groups.toSorted().join("");
|
||||
const allocation = ANNEX_C_THIRD_PLACE_ALLOCATION[key];
|
||||
|
||||
const assignment = new Map<number, string>();
|
||||
if (allocation && allocation.length === THIRD_PLACE_WINNER_COLUMNS.length) {
|
||||
THIRD_PLACE_WINNER_COLUMNS.forEach((winnerGroup, i) => {
|
||||
const thirdGroup = allocation[i];
|
||||
const slotId = [...WINNER_BY_THIRD_SLOT].find(([, w]) => w === winnerGroup)?.[0];
|
||||
if (slotId !== undefined) assignment.set(slotId, thirdGroup);
|
||||
});
|
||||
return assignment;
|
||||
}
|
||||
|
||||
return assignThirdPlaceSlotsByMatching(groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrained-matching fallback used only when no Annex C row applies (non-8
|
||||
* qualifying groups). A perfect, eligibility-respecting matching is found via
|
||||
* backtracking so the simulation still completes deterministically.
|
||||
*/
|
||||
function assignThirdPlaceSlotsByMatching(groups: string[]): Map<number, string> {
|
||||
const slots = THIRD_PLACE_SLOTS.map((s) => ({
|
||||
thirdSlotId: s.thirdSlotId,
|
||||
options: groups.filter((g) => s.eligibleGroups.includes(g)),
|
||||
})).toSorted((a, b) => a.options.length - b.options.length || a.thirdSlotId - b.thirdSlotId);
|
||||
|
||||
const assignment = new Map<number, string>();
|
||||
const used = new Set<string>();
|
||||
|
||||
const backtrack = (i: number): boolean => {
|
||||
if (i === slots.length) return true;
|
||||
for (const group of slots[i].options) {
|
||||
if (used.has(group)) continue;
|
||||
assignment.set(slots[i].thirdSlotId, group);
|
||||
used.add(group);
|
||||
if (backtrack(i + 1)) return true;
|
||||
used.delete(group);
|
||||
assignment.delete(slots[i].thirdSlotId);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (backtrack(0)) return assignment;
|
||||
|
||||
const leftover = groups.filter((g) => !used.has(g));
|
||||
for (const s of THIRD_PLACE_SLOTS) {
|
||||
if (assignment.has(s.thirdSlotId)) continue;
|
||||
const g = leftover.shift();
|
||||
if (g) assignment.set(s.thirdSlotId, g);
|
||||
}
|
||||
return assignment;
|
||||
}
|
||||
|
|
@ -1,506 +0,0 @@
|
|||
// AUTO-GENERATED from FIFA 2026 World Cup Annex C third-place allocation.
|
||||
// Source: Wikipedia "Template:2026 FIFA World Cup third-place table".
|
||||
// Do not edit by hand — regenerate with scripts/gen-fifa-third-place.mjs.
|
||||
//
|
||||
// Key: the eight qualifying third-place group letters, sorted (e.g. "EFGHIJKL").
|
||||
// Value: the qualifying third-place group facing each group winner, in the
|
||||
// column order [1A, 1B, 1D, 1E, 1G, 1I, 1K, 1L].
|
||||
export const THIRD_PLACE_WINNER_COLUMNS = ["A", "B", "D", "E", "G", "I", "K", "L"] as const;
|
||||
|
||||
export const ANNEX_C_THIRD_PLACE_ALLOCATION: Record<string, string> = {
|
||||
ABCDEFGH: "HGBCAFDE",
|
||||
ABCDEFGI: "CGBDAFEI",
|
||||
ABCDEFGJ: "CGBDAFEJ",
|
||||
ABCDEFGK: "CGBDAFEK",
|
||||
ABCDEFGL: "CGBDAFLE",
|
||||
ABCDEFHI: "HEBCAFDI",
|
||||
ABCDEFHJ: "HJBCAFDE",
|
||||
ABCDEFHK: "HEBCAFDK",
|
||||
ABCDEFHL: "HFBCADLE",
|
||||
ABCDEFIJ: "CJBDAFEI",
|
||||
ABCDEFIK: "CEBDAFIK",
|
||||
ABCDEFIL: "CEBDAFLI",
|
||||
ABCDEFJK: "CJBDAFEK",
|
||||
ABCDEFJL: "CJBDAFLE",
|
||||
ABCDEFKL: "CEBDAFLK",
|
||||
ABCDEGHI: "HGBCADEI",
|
||||
ABCDEGHJ: "HGBCADEJ",
|
||||
ABCDEGHK: "HGBCADEK",
|
||||
ABCDEGHL: "HGBCADLE",
|
||||
ABCDEGIJ: "EGBCADIJ",
|
||||
ABCDEGIK: "EGBCADIK",
|
||||
ABCDEGIL: "EGBCADLI",
|
||||
ABCDEGJK: "EGBCADJK",
|
||||
ABCDEGJL: "EGBCADLJ",
|
||||
ABCDEGKL: "EGBCADLK",
|
||||
ABCDEHIJ: "HJBCADEI",
|
||||
ABCDEHIK: "HEBCADIK",
|
||||
ABCDEHIL: "HEBCADLI",
|
||||
ABCDEHJK: "HJBCADEK",
|
||||
ABCDEHJL: "HJBCADLE",
|
||||
ABCDEHKL: "HEBCADLK",
|
||||
ABCDEIJK: "EJBCADIK",
|
||||
ABCDEIJL: "EJBCADLI",
|
||||
ABCDEIKL: "EIBCADLK",
|
||||
ABCDEJKL: "EJBCADLK",
|
||||
ABCDFGHI: "HGBCAFDI",
|
||||
ABCDFGHJ: "HGBCAFDJ",
|
||||
ABCDFGHK: "HGBCAFDK",
|
||||
ABCDFGHL: "CGBDAFLH",
|
||||
ABCDFGIJ: "CGBDAFIJ",
|
||||
ABCDFGIK: "CGBDAFIK",
|
||||
ABCDFGIL: "CGBDAFLI",
|
||||
ABCDFGJK: "CGBDAFJK",
|
||||
ABCDFGJL: "CGBDAFLJ",
|
||||
ABCDFGKL: "CGBDAFLK",
|
||||
ABCDFHIJ: "HJBCAFDI",
|
||||
ABCDFHIK: "HFBCADIK",
|
||||
ABCDFHIL: "HFBCADLI",
|
||||
ABCDFHJK: "HJBCAFDK",
|
||||
ABCDFHJL: "CJBDAFLH",
|
||||
ABCDFHKL: "HFBCADLK",
|
||||
ABCDFIJK: "CJBDAFIK",
|
||||
ABCDFIJL: "CJBDAFLI",
|
||||
ABCDFIKL: "CIBDAFLK",
|
||||
ABCDFJKL: "CJBDAFLK",
|
||||
ABCDGHIJ: "HGBCADIJ",
|
||||
ABCDGHIK: "HGBCADIK",
|
||||
ABCDGHIL: "HGBCADLI",
|
||||
ABCDGHJK: "HGBCADJK",
|
||||
ABCDGHJL: "HGBCADLJ",
|
||||
ABCDGHKL: "HGBCADLK",
|
||||
ABCDGIJK: "CJBDAGIK",
|
||||
ABCDGIJL: "CJBDAGLI",
|
||||
ABCDGIKL: "IGBCADLK",
|
||||
ABCDGJKL: "CJBDAGLK",
|
||||
ABCDHIJK: "HJBCADIK",
|
||||
ABCDHIJL: "HJBCADLI",
|
||||
ABCDHIKL: "HIBCADLK",
|
||||
ABCDHJKL: "HJBCADLK",
|
||||
ABCDIJKL: "IJBCADLK",
|
||||
ABCEFGHI: "HGBCAFEI",
|
||||
ABCEFGHJ: "HGBCAFEJ",
|
||||
ABCEFGHK: "HGBCAFEK",
|
||||
ABCEFGHL: "HGBCAFLE",
|
||||
ABCEFGIJ: "EGBCAFIJ",
|
||||
ABCEFGIK: "EGBCAFIK",
|
||||
ABCEFGIL: "EGBCAFLI",
|
||||
ABCEFGJK: "EGBCAFJK",
|
||||
ABCEFGJL: "EGBCAFLJ",
|
||||
ABCEFGKL: "EGBCAFLK",
|
||||
ABCEFHIJ: "HJBCAFEI",
|
||||
ABCEFHIK: "HEBCAFIK",
|
||||
ABCEFHIL: "HEBCAFLI",
|
||||
ABCEFHJK: "HJBCAFEK",
|
||||
ABCEFHJL: "HJBCAFLE",
|
||||
ABCEFHKL: "HEBCAFLK",
|
||||
ABCEFIJK: "EJBCAFIK",
|
||||
ABCEFIJL: "EJBCAFLI",
|
||||
ABCEFIKL: "EIBCAFLK",
|
||||
ABCEFJKL: "EJBCAFLK",
|
||||
ABCEGHIJ: "HJBCAGEI",
|
||||
ABCEGHIK: "EGBCAHIK",
|
||||
ABCEGHIL: "EGBCAHLI",
|
||||
ABCEGHJK: "HJBCAGEK",
|
||||
ABCEGHJL: "HJBCAGLE",
|
||||
ABCEGHKL: "EGBCAHLK",
|
||||
ABCEGIJK: "EJBCAGIK",
|
||||
ABCEGIJL: "EJBCAGLI",
|
||||
ABCEGIKL: "EGBAICLK",
|
||||
ABCEGJKL: "EJBCAGLK",
|
||||
ABCEHIJK: "EJBCAHIK",
|
||||
ABCEHIJL: "EJBCAHLI",
|
||||
ABCEHIKL: "EIBCAHLK",
|
||||
ABCEHJKL: "EJBCAHLK",
|
||||
ABCEIJKL: "EJBAICLK",
|
||||
ABCFGHIJ: "HGBCAFIJ",
|
||||
ABCFGHIK: "HGBCAFIK",
|
||||
ABCFGHIL: "HGBCAFLI",
|
||||
ABCFGHJK: "HGBCAFJK",
|
||||
ABCFGHJL: "HGBCAFLJ",
|
||||
ABCFGHKL: "HGBCAFLK",
|
||||
ABCFGIJK: "CJBFAGIK",
|
||||
ABCFGIJL: "CJBFAGLI",
|
||||
ABCFGIKL: "IGBCAFLK",
|
||||
ABCFGJKL: "CJBFAGLK",
|
||||
ABCFHIJK: "HJBCAFIK",
|
||||
ABCFHIJL: "HJBCAFLI",
|
||||
ABCFHIKL: "HIBCAFLK",
|
||||
ABCFHJKL: "HJBCAFLK",
|
||||
ABCFIJKL: "IJBCAFLK",
|
||||
ABCGHIJK: "HJBCAGIK",
|
||||
ABCGHIJL: "HJBCAGLI",
|
||||
ABCGHIKL: "IGBCAHLK",
|
||||
ABCGHJKL: "HJBCAGLK",
|
||||
ABCGIJKL: "IJBCAGLK",
|
||||
ABCHIJKL: "IJBCAHLK",
|
||||
ABDEFGHI: "HGBDAFEI",
|
||||
ABDEFGHJ: "HGBDAFEJ",
|
||||
ABDEFGHK: "HGBDAFEK",
|
||||
ABDEFGHL: "HGBDAFLE",
|
||||
ABDEFGIJ: "EGBDAFIJ",
|
||||
ABDEFGIK: "EGBDAFIK",
|
||||
ABDEFGIL: "EGBDAFLI",
|
||||
ABDEFGJK: "EGBDAFJK",
|
||||
ABDEFGJL: "EGBDAFLJ",
|
||||
ABDEFGKL: "EGBDAFLK",
|
||||
ABDEFHIJ: "HJBDAFEI",
|
||||
ABDEFHIK: "HEBDAFIK",
|
||||
ABDEFHIL: "HEBDAFLI",
|
||||
ABDEFHJK: "HJBDAFEK",
|
||||
ABDEFHJL: "HJBDAFLE",
|
||||
ABDEFHKL: "HEBDAFLK",
|
||||
ABDEFIJK: "EJBDAFIK",
|
||||
ABDEFIJL: "EJBDAFLI",
|
||||
ABDEFIKL: "EIBDAFLK",
|
||||
ABDEFJKL: "EJBDAFLK",
|
||||
ABDEGHIJ: "HJBDAGEI",
|
||||
ABDEGHIK: "EGBDAHIK",
|
||||
ABDEGHIL: "EGBDAHLI",
|
||||
ABDEGHJK: "HJBDAGEK",
|
||||
ABDEGHJL: "HJBDAGLE",
|
||||
ABDEGHKL: "EGBDAHLK",
|
||||
ABDEGIJK: "EJBDAGIK",
|
||||
ABDEGIJL: "EJBDAGLI",
|
||||
ABDEGIKL: "EGBAIDLK",
|
||||
ABDEGJKL: "EJBDAGLK",
|
||||
ABDEHIJK: "EJBDAHIK",
|
||||
ABDEHIJL: "EJBDAHLI",
|
||||
ABDEHIKL: "EIBDAHLK",
|
||||
ABDEHJKL: "EJBDAHLK",
|
||||
ABDEIJKL: "EJBAIDLK",
|
||||
ABDFGHIJ: "HGBDAFIJ",
|
||||
ABDFGHIK: "HGBDAFIK",
|
||||
ABDFGHIL: "HGBDAFLI",
|
||||
ABDFGHJK: "HGBDAFJK",
|
||||
ABDFGHJL: "HGBDAFLJ",
|
||||
ABDFGHKL: "HGBDAFLK",
|
||||
ABDFGIJK: "FJBDAGIK",
|
||||
ABDFGIJL: "FJBDAGLI",
|
||||
ABDFGIKL: "IGBDAFLK",
|
||||
ABDFGJKL: "FJBDAGLK",
|
||||
ABDFHIJK: "HJBDAFIK",
|
||||
ABDFHIJL: "HJBDAFLI",
|
||||
ABDFHIKL: "HIBDAFLK",
|
||||
ABDFHJKL: "HJBDAFLK",
|
||||
ABDFIJKL: "IJBDAFLK",
|
||||
ABDGHIJK: "HJBDAGIK",
|
||||
ABDGHIJL: "HJBDAGLI",
|
||||
ABDGHIKL: "IGBDAHLK",
|
||||
ABDGHJKL: "HJBDAGLK",
|
||||
ABDGIJKL: "IJBDAGLK",
|
||||
ABDHIJKL: "IJBDAHLK",
|
||||
ABEFGHIJ: "HJBFAGEI",
|
||||
ABEFGHIK: "EGBFAHIK",
|
||||
ABEFGHIL: "EGBFAHLI",
|
||||
ABEFGHJK: "HJBFAGEK",
|
||||
ABEFGHJL: "HJBFAGLE",
|
||||
ABEFGHKL: "EGBFAHLK",
|
||||
ABEFGIJK: "EJBFAGIK",
|
||||
ABEFGIJL: "EJBFAGLI",
|
||||
ABEFGIKL: "EGBAIFLK",
|
||||
ABEFGJKL: "EJBFAGLK",
|
||||
ABEFHIJK: "EJBFAHIK",
|
||||
ABEFHIJL: "EJBFAHLI",
|
||||
ABEFHIKL: "EIBFAHLK",
|
||||
ABEFHJKL: "EJBFAHLK",
|
||||
ABEFIJKL: "EJBAIFLK",
|
||||
ABEGHIJK: "EJBAHGIK",
|
||||
ABEGHIJL: "EJBAHGLI",
|
||||
ABEGHIKL: "EGBAIHLK",
|
||||
ABEGHJKL: "EJBAHGLK",
|
||||
ABEGIJKL: "EJBAIGLK",
|
||||
ABEHIJKL: "EJBAIHLK",
|
||||
ABFGHIJK: "HJBFAGIK",
|
||||
ABFGHIJL: "HJBFAGLI",
|
||||
ABFGHIKL: "HGBAIFLK",
|
||||
ABFGHJKL: "HJBFAGLK",
|
||||
ABFGIJKL: "IJBFAGLK",
|
||||
ABFHIJKL: "HJBAIFLK",
|
||||
ABGHIJKL: "HJBAIGLK",
|
||||
ACDEFGHI: "HGECAFDI",
|
||||
ACDEFGHJ: "HGJCAFDE",
|
||||
ACDEFGHK: "HGECAFDK",
|
||||
ACDEFGHL: "HGFCADLE",
|
||||
ACDEFGIJ: "CGJDAFEI",
|
||||
ACDEFGIK: "CGEDAFIK",
|
||||
ACDEFGIL: "CGEDAFLI",
|
||||
ACDEFGJK: "CGJDAFEK",
|
||||
ACDEFGJL: "CGJDAFLE",
|
||||
ACDEFGKL: "CGEDAFLK",
|
||||
ACDEFHIJ: "HJECAFDI",
|
||||
ACDEFHIK: "HEFCADIK",
|
||||
ACDEFHIL: "HEFCADLI",
|
||||
ACDEFHJK: "HJECAFDK",
|
||||
ACDEFHJL: "HJFCADLE",
|
||||
ACDEFHKL: "HEFCADLK",
|
||||
ACDEFIJK: "CJEDAFIK",
|
||||
ACDEFIJL: "CJEDAFLI",
|
||||
ACDEFIKL: "CEIDAFLK",
|
||||
ACDEFJKL: "CJEDAFLK",
|
||||
ACDEGHIJ: "HGJCADEI",
|
||||
ACDEGHIK: "HGECADIK",
|
||||
ACDEGHIL: "HGECADLI",
|
||||
ACDEGHJK: "HGJCADEK",
|
||||
ACDEGHJL: "HGJCADLE",
|
||||
ACDEGHKL: "HGECADLK",
|
||||
ACDEGIJK: "EGJCADIK",
|
||||
ACDEGIJL: "EGJCADLI",
|
||||
ACDEGIKL: "EGICADLK",
|
||||
ACDEGJKL: "EGJCADLK",
|
||||
ACDEHIJK: "HJECADIK",
|
||||
ACDEHIJL: "HJECADLI",
|
||||
ACDEHIKL: "HEICADLK",
|
||||
ACDEHJKL: "HJECADLK",
|
||||
ACDEIJKL: "EJICADLK",
|
||||
ACDFGHIJ: "HGJCAFDI",
|
||||
ACDFGHIK: "HGFCADIK",
|
||||
ACDFGHIL: "HGFCADLI",
|
||||
ACDFGHJK: "HGJCAFDK",
|
||||
ACDFGHJL: "CGJDAFLH",
|
||||
ACDFGHKL: "HGFCADLK",
|
||||
ACDFGIJK: "CGJDAFIK",
|
||||
ACDFGIJL: "CGJDAFLI",
|
||||
ACDFGIKL: "CGIDAFLK",
|
||||
ACDFGJKL: "CGJDAFLK",
|
||||
ACDFHIJK: "HJFCADIK",
|
||||
ACDFHIJL: "HJFCADLI",
|
||||
ACDFHIKL: "HFICADLK",
|
||||
ACDFHJKL: "HJFCADLK",
|
||||
ACDFIJKL: "CJIDAFLK",
|
||||
ACDGHIJK: "HGJCADIK",
|
||||
ACDGHIJL: "HGJCADLI",
|
||||
ACDGHIKL: "HGICADLK",
|
||||
ACDGHJKL: "HGJCADLK",
|
||||
ACDGIJKL: "IGJCADLK",
|
||||
ACDHIJKL: "HJICADLK",
|
||||
ACEFGHIJ: "HGJCAFEI",
|
||||
ACEFGHIK: "HGECAFIK",
|
||||
ACEFGHIL: "HGECAFLI",
|
||||
ACEFGHJK: "HGJCAFEK",
|
||||
ACEFGHJL: "HGJCAFLE",
|
||||
ACEFGHKL: "HGECAFLK",
|
||||
ACEFGIJK: "EGJCAFIK",
|
||||
ACEFGIJL: "EGJCAFLI",
|
||||
ACEFGIKL: "EGICAFLK",
|
||||
ACEFGJKL: "EGJCAFLK",
|
||||
ACEFHIJK: "HJECAFIK",
|
||||
ACEFHIJL: "HJECAFLI",
|
||||
ACEFHIKL: "HEICAFLK",
|
||||
ACEFHJKL: "HJECAFLK",
|
||||
ACEFIJKL: "EJICAFLK",
|
||||
ACEGHIJK: "EGJCAHIK",
|
||||
ACEGHIJL: "EGJCAHLI",
|
||||
ACEGHIKL: "EGICAHLK",
|
||||
ACEGHJKL: "EGJCAHLK",
|
||||
ACEGIJKL: "EJICAGLK",
|
||||
ACEHIJKL: "EJICAHLK",
|
||||
ACFGHIJK: "HGJCAFIK",
|
||||
ACFGHIJL: "HGJCAFLI",
|
||||
ACFGHIKL: "HGICAFLK",
|
||||
ACFGHJKL: "HGJCAFLK",
|
||||
ACFGIJKL: "IGJCAFLK",
|
||||
ACFHIJKL: "HJICAFLK",
|
||||
ACGHIJKL: "HJICAGLK",
|
||||
ADEFGHIJ: "HGJDAFEI",
|
||||
ADEFGHIK: "HGEDAFIK",
|
||||
ADEFGHIL: "HGEDAFLI",
|
||||
ADEFGHJK: "HGJDAFEK",
|
||||
ADEFGHJL: "HGJDAFLE",
|
||||
ADEFGHKL: "HGEDAFLK",
|
||||
ADEFGIJK: "EGJDAFIK",
|
||||
ADEFGIJL: "EGJDAFLI",
|
||||
ADEFGIKL: "EGIDAFLK",
|
||||
ADEFGJKL: "EGJDAFLK",
|
||||
ADEFHIJK: "HJEDAFIK",
|
||||
ADEFHIJL: "HJEDAFLI",
|
||||
ADEFHIKL: "HEIDAFLK",
|
||||
ADEFHJKL: "HJEDAFLK",
|
||||
ADEFIJKL: "EJIDAFLK",
|
||||
ADEGHIJK: "EGJDAHIK",
|
||||
ADEGHIJL: "EGJDAHLI",
|
||||
ADEGHIKL: "EGIDAHLK",
|
||||
ADEGHJKL: "EGJDAHLK",
|
||||
ADEGIJKL: "EJIDAGLK",
|
||||
ADEHIJKL: "EJIDAHLK",
|
||||
ADFGHIJK: "HGJDAFIK",
|
||||
ADFGHIJL: "HGJDAFLI",
|
||||
ADFGHIKL: "HGIDAFLK",
|
||||
ADFGHJKL: "HGJDAFLK",
|
||||
ADFGIJKL: "IGJDAFLK",
|
||||
ADFHIJKL: "HJIDAFLK",
|
||||
ADGHIJKL: "HJIDAGLK",
|
||||
AEFGHIJK: "EGJFAHIK",
|
||||
AEFGHIJL: "EGJFAHLI",
|
||||
AEFGHIKL: "EGIFAHLK",
|
||||
AEFGHJKL: "EGJFAHLK",
|
||||
AEFGIJKL: "EJIFAGLK",
|
||||
AEFHIJKL: "EJIFAHLK",
|
||||
AEGHIJKL: "EJIAHGLK",
|
||||
AFGHIJKL: "HJIFAGLK",
|
||||
BCDEFGHI: "CGBDHFEI",
|
||||
BCDEFGHJ: "HGBCJFDE",
|
||||
BCDEFGHK: "CGBDHFEK",
|
||||
BCDEFGHL: "CGBDHFLE",
|
||||
BCDEFGIJ: "CGBDJFEI",
|
||||
BCDEFGIK: "CGBDEFIK",
|
||||
BCDEFGIL: "CGBDEFLI",
|
||||
BCDEFGJK: "CGBDJFEK",
|
||||
BCDEFGJL: "CGBDJFLE",
|
||||
BCDEFGKL: "CGBDEFLK",
|
||||
BCDEFHIJ: "CJBDHFEI",
|
||||
BCDEFHIK: "CEBDHFIK",
|
||||
BCDEFHIL: "CEBDHFLI",
|
||||
BCDEFHJK: "CJBDHFEK",
|
||||
BCDEFHJL: "CJBDHFLE",
|
||||
BCDEFHKL: "CEBDHFLK",
|
||||
BCDEFIJK: "CJBDEFIK",
|
||||
BCDEFIJL: "CJBDEFLI",
|
||||
BCDEFIKL: "CEBDIFLK",
|
||||
BCDEFJKL: "CJBDEFLK",
|
||||
BCDEGHIJ: "HGBCJDEI",
|
||||
BCDEGHIK: "EGBCHDIK",
|
||||
BCDEGHIL: "EGBCHDLI",
|
||||
BCDEGHJK: "HGBCJDEK",
|
||||
BCDEGHJL: "HGBCJDLE",
|
||||
BCDEGHKL: "EGBCHDLK",
|
||||
BCDEGIJK: "EGBCJDIK",
|
||||
BCDEGIJL: "EGBCJDLI",
|
||||
BCDEGIKL: "EGBCIDLK",
|
||||
BCDEGJKL: "EGBCJDLK",
|
||||
BCDEHIJK: "EJBCHDIK",
|
||||
BCDEHIJL: "EJBCHDLI",
|
||||
BCDEHIKL: "EIBCHDLK",
|
||||
BCDEHJKL: "EJBCHDLK",
|
||||
BCDEIJKL: "EJBCIDLK",
|
||||
BCDFGHIJ: "HGBCJFDI",
|
||||
BCDFGHIK: "CGBDHFIK",
|
||||
BCDFGHIL: "CGBDHFLI",
|
||||
BCDFGHJK: "HGBCJFDK",
|
||||
BCDFGHJL: "CGBDHFLJ",
|
||||
BCDFGHKL: "CGBDHFLK",
|
||||
BCDFGIJK: "CGBDJFIK",
|
||||
BCDFGIJL: "CGBDJFLI",
|
||||
BCDFGIKL: "CGBDIFLK",
|
||||
BCDFGJKL: "CGBDJFLK",
|
||||
BCDFHIJK: "CJBDHFIK",
|
||||
BCDFHIJL: "CJBDHFLI",
|
||||
BCDFHIKL: "CIBDHFLK",
|
||||
BCDFHJKL: "CJBDHFLK",
|
||||
BCDFIJKL: "CJBDIFLK",
|
||||
BCDGHIJK: "HGBCJDIK",
|
||||
BCDGHIJL: "HGBCJDLI",
|
||||
BCDGHIKL: "HGBCIDLK",
|
||||
BCDGHJKL: "HGBCJDLK",
|
||||
BCDGIJKL: "IGBCJDLK",
|
||||
BCDHIJKL: "HJBCIDLK",
|
||||
BCEFGHIJ: "HGBCJFEI",
|
||||
BCEFGHIK: "EGBCHFIK",
|
||||
BCEFGHIL: "EGBCHFLI",
|
||||
BCEFGHJK: "HGBCJFEK",
|
||||
BCEFGHJL: "HGBCJFLE",
|
||||
BCEFGHKL: "EGBCHFLK",
|
||||
BCEFGIJK: "EGBCJFIK",
|
||||
BCEFGIJL: "EGBCJFLI",
|
||||
BCEFGIKL: "EGBCIFLK",
|
||||
BCEFGJKL: "EGBCJFLK",
|
||||
BCEFHIJK: "EJBCHFIK",
|
||||
BCEFHIJL: "EJBCHFLI",
|
||||
BCEFHIKL: "EIBCHFLK",
|
||||
BCEFHJKL: "EJBCHFLK",
|
||||
BCEFIJKL: "EJBCIFLK",
|
||||
BCEGHIJK: "EJBCHGIK",
|
||||
BCEGHIJL: "EJBCHGLI",
|
||||
BCEGHIKL: "EGBCIHLK",
|
||||
BCEGHJKL: "EJBCHGLK",
|
||||
BCEGIJKL: "EJBCIGLK",
|
||||
BCEHIJKL: "EJBCIHLK",
|
||||
BCFGHIJK: "HGBCJFIK",
|
||||
BCFGHIJL: "HGBCJFLI",
|
||||
BCFGHIKL: "HGBCIFLK",
|
||||
BCFGHJKL: "HGBCJFLK",
|
||||
BCFGIJKL: "IGBCJFLK",
|
||||
BCFHIJKL: "HJBCIFLK",
|
||||
BCGHIJKL: "HJBCIGLK",
|
||||
BDEFGHIJ: "HGBDJFEI",
|
||||
BDEFGHIK: "EGBDHFIK",
|
||||
BDEFGHIL: "EGBDHFLI",
|
||||
BDEFGHJK: "HGBDJFEK",
|
||||
BDEFGHJL: "HGBDJFLE",
|
||||
BDEFGHKL: "EGBDHFLK",
|
||||
BDEFGIJK: "EGBDJFIK",
|
||||
BDEFGIJL: "EGBDJFLI",
|
||||
BDEFGIKL: "EGBDIFLK",
|
||||
BDEFGJKL: "EGBDJFLK",
|
||||
BDEFHIJK: "EJBDHFIK",
|
||||
BDEFHIJL: "EJBDHFLI",
|
||||
BDEFHIKL: "EIBDHFLK",
|
||||
BDEFHJKL: "EJBDHFLK",
|
||||
BDEFIJKL: "EJBDIFLK",
|
||||
BDEGHIJK: "EJBDHGIK",
|
||||
BDEGHIJL: "EJBDHGLI",
|
||||
BDEGHIKL: "EGBDIHLK",
|
||||
BDEGHJKL: "EJBDHGLK",
|
||||
BDEGIJKL: "EJBDIGLK",
|
||||
BDEHIJKL: "EJBDIHLK",
|
||||
BDFGHIJK: "HGBDJFIK",
|
||||
BDFGHIJL: "HGBDJFLI",
|
||||
BDFGHIKL: "HGBDIFLK",
|
||||
BDFGHJKL: "HGBDJFLK",
|
||||
BDFGIJKL: "IGBDJFLK",
|
||||
BDFHIJKL: "HJBDIFLK",
|
||||
BDGHIJKL: "HJBDIGLK",
|
||||
BEFGHIJK: "EJBFHGIK",
|
||||
BEFGHIJL: "EJBFHGLI",
|
||||
BEFGHIKL: "EGBFIHLK",
|
||||
BEFGHJKL: "EJBFHGLK",
|
||||
BEFGIJKL: "EJBFIGLK",
|
||||
BEFHIJKL: "EJBFIHLK",
|
||||
BEGHIJKL: "EJIBHGLK",
|
||||
BFGHIJKL: "HJBFIGLK",
|
||||
CDEFGHIJ: "CGJDHFEI",
|
||||
CDEFGHIK: "CGEDHFIK",
|
||||
CDEFGHIL: "CGEDHFLI",
|
||||
CDEFGHJK: "CGJDHFEK",
|
||||
CDEFGHJL: "CGJDHFLE",
|
||||
CDEFGHKL: "CGEDHFLK",
|
||||
CDEFGIJK: "CGEDJFIK",
|
||||
CDEFGIJL: "CGEDJFLI",
|
||||
CDEFGIKL: "CGEDIFLK",
|
||||
CDEFGJKL: "CGEDJFLK",
|
||||
CDEFHIJK: "CJEDHFIK",
|
||||
CDEFHIJL: "CJEDHFLI",
|
||||
CDEFHIKL: "CEIDHFLK",
|
||||
CDEFHJKL: "CJEDHFLK",
|
||||
CDEFIJKL: "CJEDIFLK",
|
||||
CDEGHIJK: "EGJCHDIK",
|
||||
CDEGHIJL: "EGJCHDLI",
|
||||
CDEGHIKL: "EGICHDLK",
|
||||
CDEGHJKL: "EGJCHDLK",
|
||||
CDEGIJKL: "EGICJDLK",
|
||||
CDEHIJKL: "EJICHDLK",
|
||||
CDFGHIJK: "CGJDHFIK",
|
||||
CDFGHIJL: "CGJDHFLI",
|
||||
CDFGHIKL: "CGIDHFLK",
|
||||
CDFGHJKL: "CGJDHFLK",
|
||||
CDFGIJKL: "CGIDJFLK",
|
||||
CDFHIJKL: "CJIDHFLK",
|
||||
CDGHIJKL: "HGICJDLK",
|
||||
CEFGHIJK: "EGJCHFIK",
|
||||
CEFGHIJL: "EGJCHFLI",
|
||||
CEFGHIKL: "EGICHFLK",
|
||||
CEFGHJKL: "EGJCHFLK",
|
||||
CEFGIJKL: "EGICJFLK",
|
||||
CEFHIJKL: "EJICHFLK",
|
||||
CEGHIJKL: "EJICHGLK",
|
||||
CFGHIJKL: "HGICJFLK",
|
||||
DEFGHIJK: "EGJDHFIK",
|
||||
DEFGHIJL: "EGJDHFLI",
|
||||
DEFGHIKL: "EGIDHFLK",
|
||||
DEFGHJKL: "EGJDHFLK",
|
||||
DEFGIJKL: "EGIDJFLK",
|
||||
DEFHIJKL: "EJIDHFLK",
|
||||
DEGHIJKL: "EJIDHGLK",
|
||||
DFGHIJKL: "HGIDJFLK",
|
||||
EFGHIJKL: "EJIFHGLK",
|
||||
};
|
||||
|
|
@ -1,194 +0,0 @@
|
|||
/**
|
||||
* LLWS 20-team double-elimination routing — the pure half of the bracket.
|
||||
*
|
||||
* Lives in lib/ rather than models/ because the renderer needs it: models/playoff-match
|
||||
* pulls in the database context and drizzle, which must not reach the browser bundle.
|
||||
* models/playoff-match re-exports everything here, so server-side callers are unchanged.
|
||||
*/
|
||||
|
||||
import { llwsMatchNumber, llwsSideAndLocal } from "~/lib/bracket-templates";
|
||||
|
||||
/**
|
||||
* Where one participant goes after an LLWS match: a round, a side-local match number,
|
||||
* and which slot to fill. `null` means eliminated (or, for winners, no further game).
|
||||
*/
|
||||
interface LLWSDestination {
|
||||
round: string;
|
||||
localMatch: number;
|
||||
slot: "participant1Id" | "participant2Id";
|
||||
}
|
||||
|
||||
/**
|
||||
* LLWS advancement map, in SIDE-LOCAL match numbers.
|
||||
*
|
||||
* Keyed by round, then by the local match number of the completed game. Each entry
|
||||
* says where the winner goes and where the loser goes (null = eliminated).
|
||||
*
|
||||
* Verified game-by-game against the official 2026 LLBWS bracket. Note the deliberate
|
||||
* cross-overs — the elimination bracket does NOT feed straight across:
|
||||
* Elim R1: L(Opening m2) v L(Opening m3) and L(Opening m1) v L(Opening m4)
|
||||
* Elim R3: L(Semi m1) v W(Elim R2 m2) and L(Semi m2) v W(Elim R2 m1)
|
||||
* Elim R4: W(Elim R3 m1) v W(Elim R3 m2)
|
||||
*
|
||||
* A loss in the winners bracket routes into the elimination bracket rather than
|
||||
* eliminating the team; a loss in the elimination bracket is final.
|
||||
*/
|
||||
const LLWS_ADVANCEMENT: Record<
|
||||
string,
|
||||
Record<number, { winner: LLWSDestination | null; loser: LLWSDestination | null }>
|
||||
> = {
|
||||
"Opening Round": {
|
||||
1: {
|
||||
winner: { round: "Winners Round 2", localMatch: 1, slot: "participant2Id" },
|
||||
loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant1Id" },
|
||||
},
|
||||
2: {
|
||||
winner: { round: "Winners Round 2", localMatch: 2, slot: "participant2Id" },
|
||||
loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant1Id" },
|
||||
},
|
||||
3: {
|
||||
winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant1Id" },
|
||||
loser: { round: "Elimination Round 1", localMatch: 1, slot: "participant2Id" },
|
||||
},
|
||||
4: {
|
||||
winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant2Id" },
|
||||
loser: { round: "Elimination Round 1", localMatch: 2, slot: "participant2Id" },
|
||||
},
|
||||
},
|
||||
"Winners Round 2": {
|
||||
1: {
|
||||
winner: { round: "Winners Semifinals", localMatch: 1, slot: "participant2Id" },
|
||||
loser: { round: "Elimination Round 2", localMatch: 1, slot: "participant1Id" },
|
||||
},
|
||||
2: {
|
||||
winner: { round: "Winners Semifinals", localMatch: 2, slot: "participant1Id" },
|
||||
loser: { round: "Elimination Round 2", localMatch: 2, slot: "participant1Id" },
|
||||
},
|
||||
},
|
||||
"Winners Semifinals": {
|
||||
1: {
|
||||
winner: { round: "Winners Final", localMatch: 1, slot: "participant1Id" },
|
||||
loser: { round: "Elimination Round 3", localMatch: 1, slot: "participant1Id" },
|
||||
},
|
||||
2: {
|
||||
winner: { round: "Winners Final", localMatch: 1, slot: "participant2Id" },
|
||||
loser: { round: "Elimination Round 3", localMatch: 2, slot: "participant1Id" },
|
||||
},
|
||||
},
|
||||
"Winners Final": {
|
||||
1: {
|
||||
winner: { round: "Bracket Championship", localMatch: 1, slot: "participant1Id" },
|
||||
// A winners-bracket final loss is not an elimination — it drops to the
|
||||
// Elimination Final for a second chance at the side championship.
|
||||
loser: { round: "Elimination Final", localMatch: 1, slot: "participant1Id" },
|
||||
},
|
||||
},
|
||||
"Elimination Round 1": {
|
||||
1: {
|
||||
winner: { round: "Elimination Round 2", localMatch: 1, slot: "participant2Id" },
|
||||
loser: null,
|
||||
},
|
||||
2: {
|
||||
winner: { round: "Elimination Round 2", localMatch: 2, slot: "participant2Id" },
|
||||
loser: null,
|
||||
},
|
||||
},
|
||||
"Elimination Round 2": {
|
||||
// Cross-over: R2 m1's winner meets the OTHER semifinal loser.
|
||||
1: {
|
||||
winner: { round: "Elimination Round 3", localMatch: 2, slot: "participant2Id" },
|
||||
loser: null,
|
||||
},
|
||||
2: {
|
||||
winner: { round: "Elimination Round 3", localMatch: 1, slot: "participant2Id" },
|
||||
loser: null,
|
||||
},
|
||||
},
|
||||
"Elimination Round 3": {
|
||||
// The later game (m2) is printed on top: G32 = W28 v W26, G31 = W27 v W25.
|
||||
1: {
|
||||
winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant2Id" },
|
||||
loser: null,
|
||||
},
|
||||
2: {
|
||||
winner: { round: "Elimination Round 4", localMatch: 1, slot: "participant1Id" },
|
||||
loser: null,
|
||||
},
|
||||
},
|
||||
"Elimination Round 4": {
|
||||
1: {
|
||||
winner: { round: "Elimination Final", localMatch: 1, slot: "participant2Id" },
|
||||
loser: null,
|
||||
},
|
||||
},
|
||||
"Elimination Final": {
|
||||
1: {
|
||||
winner: { round: "Bracket Championship", localMatch: 1, slot: "participant2Id" },
|
||||
loser: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Rounds whose losers drop into the elimination bracket instead of going out. */
|
||||
export const LLWS_LOSER_ADVANCES_ROUNDS = new Set([
|
||||
"Opening Round",
|
||||
"Winners Round 2",
|
||||
"Winners Semifinals",
|
||||
]);
|
||||
|
||||
/** A resolved LLWS destination, in global (not side-local) match numbers. */
|
||||
export interface LLWSResolvedDestination {
|
||||
round: string;
|
||||
matchNumber: number;
|
||||
slot: "participant1Id" | "participant2Id";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve where the winner and loser of a completed LLWS match go, in global match
|
||||
* numbers. `null` means that participant has no further game (eliminated, or the
|
||||
* tournament is over for them).
|
||||
*
|
||||
* Pure — no DB access — so the whole 38-game routing can be verified against the
|
||||
* official bracket in tests. advanceLLWSWinner is a thin writer on top of this.
|
||||
*/
|
||||
export function resolveLLWSAdvancement(
|
||||
round: string,
|
||||
matchNumber: number
|
||||
): { winner: LLWSResolvedDestination | null; loser: LLWSResolvedDestination | null } {
|
||||
// Terminal rounds — nobody advances.
|
||||
if (round === "Consolation Third Place" || round === "World Championship") {
|
||||
return { winner: null, loser: null };
|
||||
}
|
||||
|
||||
// Bracket Championship is the crossover: the winner goes to the World Championship
|
||||
// and the loser to the Consolation game. The side fixes the slot in both (U.S. takes
|
||||
// participant1, International participant2), so the two sides can't collide.
|
||||
if (round === "Bracket Championship") {
|
||||
const { side } = llwsSideAndLocal("Bracket Championship", matchNumber);
|
||||
const slot: "participant1Id" | "participant2Id" =
|
||||
side === 0 ? "participant1Id" : "participant2Id";
|
||||
return {
|
||||
winner: { round: "World Championship", matchNumber: 1, slot },
|
||||
loser: { round: "Consolation Third Place", matchNumber: 1, slot },
|
||||
};
|
||||
}
|
||||
|
||||
const roundMap = LLWS_ADVANCEMENT[round];
|
||||
if (!roundMap) {
|
||||
throw new Error(`Round '${round}' is not part of the LLWS bracket`);
|
||||
}
|
||||
|
||||
const { side, localMatch } = llwsSideAndLocal(round, matchNumber);
|
||||
const routes = roundMap[localMatch];
|
||||
if (!routes) {
|
||||
throw new Error(`No LLWS advancement defined for ${round} match ${matchNumber}`);
|
||||
}
|
||||
|
||||
// Winner and loser stay on their own side, so the same side offset applies to both.
|
||||
const toGlobal = (d: LLWSDestination | null): LLWSResolvedDestination | null =>
|
||||
d === null
|
||||
? null
|
||||
: { round: d.round, matchNumber: llwsMatchNumber(d.round, side, d.localMatch), slot: d.slot };
|
||||
|
||||
return { winner: toGlobal(routes.winner), loser: toGlobal(routes.loser) };
|
||||
}
|
||||
|
|
@ -1,15 +1,9 @@
|
|||
/**
|
||||
* Normalize a team/participant name for fuzzy matching across data sources.
|
||||
* Folds accents (so "Stéfanos Tsitsipás" matches a plain "Stefanos Tsitsipas"),
|
||||
* lowercases, trims, and collapses whitespace.
|
||||
* Normalize a team name for fuzzy matching across data sources.
|
||||
* Lowercases, trims, and collapses whitespace.
|
||||
*/
|
||||
export function normalizeTeamName(name: string): string {
|
||||
return name
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "") // strip combining diacritical marks
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\s+/g, " ");
|
||||
return name.toLowerCase().trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@
|
|||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { AFL_10, getScoringRoundType } from "~/lib/bracket-templates";
|
||||
import { calculateFantasyPoints, calculateAveragedPoints, calculateBracketPoints, type ScoringRules } from "../scoring-rules";
|
||||
import { getBracketEntryFloor } from "../scoring-calculator";
|
||||
import { calculateFantasyPoints, calculateAveragedPoints, type ScoringRules } from "../scoring-rules";
|
||||
|
||||
const DEFAULT_SCORING: ScoringRules = {
|
||||
pointsFor1st: 100,
|
||||
|
|
@ -207,69 +206,3 @@ describe("AFL Finals System - Phase 3.3", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AFL guaranteed floors from seeding (afl_10)", () => {
|
||||
const byName = (name: string) => AFL_10.rounds.find((r) => r.name === name);
|
||||
|
||||
describe("getBracketEntryFloor — banked the moment the bracket is set", () => {
|
||||
it("gives seeds 1-4 the 5th-6th tier: the double chance is locked in at seeding", () => {
|
||||
// Worst case for a top-4 seed is lose the Qualifying Final, then lose the
|
||||
// Semi-Final — which is the 5th-6th tier. They can never finish below it.
|
||||
expect(getBracketEntryFloor("Qualifying Finals", "afl_10")).toBe(5);
|
||||
expect(calculateBracketPoints(5, DEFAULT_SCORING, "afl_10")).toBe(25);
|
||||
});
|
||||
|
||||
it("gives seeds 5-6 the 7th-8th tier: they are seeded straight into a scoring round", () => {
|
||||
expect(getBracketEntryFloor("Elimination Finals", "afl_10")).toBe(7);
|
||||
expect(calculateBracketPoints(7, DEFAULT_SCORING, "afl_10")).toBe(15);
|
||||
});
|
||||
|
||||
it("gives seeds 7-10 nothing: a Wildcard loss is worth 0", () => {
|
||||
expect(getBracketEntryFloor("Wildcard Round", "afl_10")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the template is unknown or missing", () => {
|
||||
expect(getBracketEntryFloor("Qualifying Finals", null)).toBeNull();
|
||||
expect(getBracketEntryFloor("Qualifying Finals", "not_a_template")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not hand out floors for TBD rounds nobody is seeded into yet", () => {
|
||||
// These rounds do carry a loser tier, but every slot is empty at generation,
|
||||
// so applyBracketEntryFloors has no participant to write against.
|
||||
expect(byName("Semi-Finals")?.entryFloor).toBeUndefined();
|
||||
expect(byName("Preliminary Finals")?.entryFloor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("nonScoringWinnerFloor — the generic top-8 default is wrong for both AFL non-scoring rounds", () => {
|
||||
it("Qualifying Finals winners bank 3, not 5 — the bye means a Prelim loss is 3rd-4th", () => {
|
||||
expect(byName("Qualifying Finals")?.nonScoringWinnerFloor).toBe(3);
|
||||
});
|
||||
|
||||
it("Wildcard winners bank 7, not 5 — winning only buys an Elimination Final", () => {
|
||||
expect(byName("Wildcard Round")?.nonScoringWinnerFloor).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe("floors only ever improve along every AFL path", () => {
|
||||
const pts = (position: number) => calculateBracketPoints(position, DEFAULT_SCORING, "afl_10");
|
||||
|
||||
it("top-4 seed: entry 5 → QF win 3 → PF win 2 → GF win 1", () => {
|
||||
expect(pts(5)).toBeLessThan(pts(3));
|
||||
expect(pts(3)).toBeLessThan(pts(2));
|
||||
expect(pts(2)).toBeLessThan(pts(1));
|
||||
});
|
||||
|
||||
it("top-4 seed losing the QF holds the entry floor, then finalizes at 5th-6th", () => {
|
||||
// QF losers advance to the Semi-Final, so nothing is written at the QF —
|
||||
// the entry floor of 5 carries them until the Semi-Final resolves.
|
||||
const entryFloor = getBracketEntryFloor("Qualifying Finals", "afl_10");
|
||||
expect(entryFloor).toBe(5);
|
||||
expect(pts(entryFloor ?? 0)).toBe(25); // unchanged by the loss
|
||||
});
|
||||
|
||||
it("seeds 5-6 and Wildcard winners share a 7th-8th floor, below the top-4's", () => {
|
||||
expect(pts(7)).toBeLessThan(pts(5));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,273 +0,0 @@
|
|||
/**
|
||||
* Advancing an AFL Elimination Finals winner into the Semi-Finals.
|
||||
*
|
||||
* Unlike the Wildcard Round, this pathway is fixed: Elimination Final n feeds Semi-Final
|
||||
* n. The crossover comes a round later, at Semi-Finals → Preliminary Finals, so that a
|
||||
* Qualifying Final loser cannot meet the side that just beat it.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { AFL_10 } from "~/lib/bracket-templates";
|
||||
|
||||
interface MatchRow {
|
||||
id: string;
|
||||
scoringEventId: string;
|
||||
round: string;
|
||||
matchNumber: number;
|
||||
participant1Id: string | null;
|
||||
participant2Id: string | null;
|
||||
isComplete: boolean;
|
||||
winnerId: string | null;
|
||||
loserId: string | null;
|
||||
}
|
||||
|
||||
let rows: MatchRow[] = [];
|
||||
|
||||
/**
|
||||
* The literal values drizzle put in a where clause (`eq(col, value)`), which is all this
|
||||
* mock needs to tell one lookup from another — there is no query engine behind it.
|
||||
*/
|
||||
function whereValues(node: unknown, depth = 0): string[] {
|
||||
if (!node || depth > 10) return [];
|
||||
if (Array.isArray(node)) return node.flatMap((child) => whereValues(child, depth + 1));
|
||||
if (typeof node !== "object") return [];
|
||||
const obj = node as Record<string, unknown>;
|
||||
const own = typeof obj.value === "string" ? [obj.value] : [];
|
||||
return [...own, ...whereValues(obj.queryChunks, depth + 1)];
|
||||
}
|
||||
|
||||
const db = {
|
||||
query: {
|
||||
playoffMatches: {
|
||||
findFirst: vi.fn(({ where }: { where: unknown }) => {
|
||||
const values = whereValues(where);
|
||||
return Promise.resolve(rows.find((r) => values.includes(r.id)));
|
||||
}),
|
||||
findMany: vi.fn(({ where }: { where: unknown }) => {
|
||||
const values = whereValues(where);
|
||||
return Promise.resolve(
|
||||
rows
|
||||
.filter((r) => values.includes(r.scoringEventId) && values.includes(r.round))
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber)
|
||||
);
|
||||
}),
|
||||
},
|
||||
},
|
||||
update: vi.fn(() => ({
|
||||
set: (data: Partial<MatchRow>) => {
|
||||
const applyTo = (where: unknown) => {
|
||||
const values = whereValues(where);
|
||||
const target = rows.find((r) => values.includes(r.id));
|
||||
if (target) Object.assign(target, data);
|
||||
return target;
|
||||
};
|
||||
// Advancement writes through the query builder with and without .returning().
|
||||
return {
|
||||
where: (where: unknown) => {
|
||||
const applied = Promise.resolve([applyTo(where)]);
|
||||
return Object.assign(applied, { returning: () => applied });
|
||||
},
|
||||
};
|
||||
},
|
||||
})),
|
||||
// No rollback: the tests assert the writes that were attempted, in order.
|
||||
transaction: vi.fn((fn: (tx: typeof db) => Promise<unknown>) => fn(db)),
|
||||
};
|
||||
|
||||
vi.mock("~/database/context", () => ({ database: () => db }));
|
||||
|
||||
const { advanceWinnerTemplate, reseedAflSemiFinals } = await import("../playoff-match");
|
||||
|
||||
const EVENT = "event-1";
|
||||
|
||||
/**
|
||||
* The real 2026 finals, which is what surfaced the crossover bug. Ladder: 1 Fremantle,
|
||||
* 2 Sydney, 3 Brisbane, 4 Hawthorn, 5 Geelong, 6 Adelaide, 7 Melbourne, 8 Bulldogs,
|
||||
* 9 Collingwood, 10 Carlton. Carlton (10th) and the Bulldogs (8th) came through the
|
||||
* Wildcard Round, so 5th hosts Carlton and 6th hosts the Bulldogs.
|
||||
*/
|
||||
const FREO = "fremantle";
|
||||
const SYDNEY = "sydney";
|
||||
const BRISBANE = "brisbane";
|
||||
const HAWTHORN = "hawthorn";
|
||||
const GEELONG = "geelong";
|
||||
const ADELAIDE = "adelaide";
|
||||
const BULLDOGS = "bulldogs";
|
||||
const CARLTON = "carlton";
|
||||
|
||||
/** An afl_10 bracket with week one played: Freo and Brisbane lost their Qualifying Finals. */
|
||||
function bracket(): MatchRow[] {
|
||||
const base = { scoringEventId: EVENT, isComplete: false, winnerId: null, loserId: null };
|
||||
return [
|
||||
{ ...base, id: "qf1", round: "Qualifying Finals", matchNumber: 1, participant1Id: FREO, participant2Id: HAWTHORN, isComplete: true, winnerId: HAWTHORN, loserId: FREO },
|
||||
{ ...base, id: "qf2", round: "Qualifying Finals", matchNumber: 2, participant1Id: SYDNEY, participant2Id: BRISBANE, isComplete: true, winnerId: SYDNEY, loserId: BRISBANE },
|
||||
{ ...base, id: "ef1", round: "Elimination Finals", matchNumber: 1, participant1Id: GEELONG, participant2Id: CARLTON },
|
||||
{ ...base, id: "ef2", round: "Elimination Finals", matchNumber: 2, participant1Id: ADELAIDE, participant2Id: BULLDOGS },
|
||||
// Filled by the Qualifying Final losers, as advancement already does.
|
||||
{ ...base, id: "sf1", round: "Semi-Finals", matchNumber: 1, participant1Id: FREO, participant2Id: null },
|
||||
{ ...base, id: "sf2", round: "Semi-Finals", matchNumber: 2, participant1Id: BRISBANE, participant2Id: null },
|
||||
{ ...base, id: "pf1", round: "Preliminary Finals", matchNumber: 1, participant1Id: HAWTHORN, participant2Id: null },
|
||||
{ ...base, id: "pf2", round: "Preliminary Finals", matchNumber: 2, participant1Id: SYDNEY, participant2Id: null },
|
||||
];
|
||||
}
|
||||
|
||||
function row(id: string): MatchRow {
|
||||
const found = rows.find((r) => r.id === id);
|
||||
if (!found) throw new Error(`No such match ${id}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Record a result the way setMatchWinner does, then advance it. */
|
||||
async function win(id: string, winnerId: string) {
|
||||
const match = row(id);
|
||||
match.winnerId = winnerId;
|
||||
match.loserId = match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
|
||||
match.isComplete = true;
|
||||
await advanceWinnerTemplate(id, winnerId, AFL_10);
|
||||
}
|
||||
|
||||
const pairing = () => ({
|
||||
sf1: [row("sf1").participant1Id, row("sf1").participant2Id],
|
||||
sf2: [row("sf2").participant1Id, row("sf2").participant2Id],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
rows = bracket();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Elimination Finals → Semi-Finals advancement", () => {
|
||||
it("feeds Elimination Final 1 into Semi-Final 1", async () => {
|
||||
await win("ef1", GEELONG);
|
||||
|
||||
expect(row("sf1").participant2Id).toBe(GEELONG);
|
||||
expect(row("sf2").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("feeds Elimination Final 2 into Semi-Final 2", async () => {
|
||||
await win("ef2", ADELAIDE);
|
||||
|
||||
expect(row("sf2").participant2Id).toBe(ADELAIDE);
|
||||
expect(row("sf1").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("draws the real 2026 Semi-Finals: Freo v Geelong and Brisbane v Adelaide", async () => {
|
||||
await win("ef1", GEELONG);
|
||||
await win("ef2", ADELAIDE);
|
||||
|
||||
expect(pairing()).toEqual({
|
||||
sf1: [FREO, GEELONG],
|
||||
sf2: [BRISBANE, ADELAIDE],
|
||||
});
|
||||
});
|
||||
|
||||
it("draws the same Semi-Finals whichever order the results are entered", async () => {
|
||||
await win("ef2", ADELAIDE);
|
||||
await win("ef1", GEELONG);
|
||||
|
||||
expect(pairing()).toEqual({
|
||||
sf1: [FREO, GEELONG],
|
||||
sf2: [BRISBANE, ADELAIDE],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the Preliminary Finals crossover so a QF loser dodges the side that beat it", async () => {
|
||||
await win("ef1", GEELONG);
|
||||
await win("ef2", ADELAIDE);
|
||||
// Freo (lost QF1 to Hawthorn) wins its semi, so it must land in Sydney's Prelim.
|
||||
await win("sf1", FREO);
|
||||
|
||||
expect(row("pf2").participant2Id).toBe(FREO);
|
||||
expect(row("pf1").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("pulls the beaten team back out when an Elimination Final result is corrected", async () => {
|
||||
await win("ef1", GEELONG);
|
||||
expect(row("sf1").participant2Id).toBe(GEELONG);
|
||||
|
||||
await win("ef1", CARLTON);
|
||||
|
||||
expect(row("sf1").participant2Id).toBe(CARLTON);
|
||||
expect(row("sf2").participant2Id).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reseedAflSemiFinals", () => {
|
||||
it("repairs a bracket left crossed by the old fixed crossover", async () => {
|
||||
// What advancement wrote before the fix: EF1 winner into SF2, EF2 winner into SF1.
|
||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
||||
Object.assign(row("ef2"), { isComplete: true, winnerId: ADELAIDE, loserId: BULLDOGS });
|
||||
row("sf1").participant2Id = ADELAIDE;
|
||||
row("sf2").participant2Id = GEELONG;
|
||||
|
||||
const reseed = await reseedAflSemiFinals(EVENT);
|
||||
|
||||
expect(pairing()).toEqual({
|
||||
sf1: [FREO, GEELONG],
|
||||
sf2: [BRISBANE, ADELAIDE],
|
||||
});
|
||||
expect(reseed.vacated.toSorted()).toEqual([1, 2]);
|
||||
expect(reseed.filled.toSorted((a, b) => a.matchNumber - b.matchNumber)).toEqual([
|
||||
{ matchNumber: 1, participantId: GEELONG },
|
||||
{ matchNumber: 2, participantId: ADELAIDE },
|
||||
]);
|
||||
});
|
||||
|
||||
it("writes nothing when the pairings are already right", async () => {
|
||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
||||
Object.assign(row("ef2"), { isComplete: true, winnerId: ADELAIDE, loserId: BULLDOGS });
|
||||
row("sf1").participant2Id = GEELONG;
|
||||
row("sf2").participant2Id = ADELAIDE;
|
||||
|
||||
const reseed = await reseedAflSemiFinals(EVENT);
|
||||
|
||||
expect(reseed).toEqual({ vacated: [], filled: [] });
|
||||
expect(db.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves an undecided Elimination Final's slot TBD", async () => {
|
||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
||||
|
||||
await reseedAflSemiFinals(EVENT);
|
||||
|
||||
expect(row("sf1").participant2Id).toBe(GEELONG);
|
||||
expect(row("sf2").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a slot held by someone who never played an Elimination Final", async () => {
|
||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
||||
row("sf1").participant2Id = SYDNEY;
|
||||
|
||||
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow("SF 1 participant2 already filled");
|
||||
});
|
||||
|
||||
it("refuses to move a qualifier out of a Semi-Final that has been played", async () => {
|
||||
Object.assign(row("ef1"), { isComplete: true, winnerId: GEELONG, loserId: CARLTON });
|
||||
Object.assign(row("sf1"), {
|
||||
participant2Id: ADELAIDE,
|
||||
isComplete: true,
|
||||
winnerId: FREO,
|
||||
loserId: ADELAIDE,
|
||||
});
|
||||
|
||||
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow(
|
||||
"Semi-Finals match 1 already has a recorded result"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an Elimination Final winner who is not one of its participants", async () => {
|
||||
Object.assign(row("ef1"), { isComplete: true, winnerId: SYDNEY, loserId: CARLTON });
|
||||
|
||||
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow(
|
||||
"Elimination Finals match 1 winner is not one of its participants"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on an event with no Semi-Finals to re-seed", async () => {
|
||||
rows = rows.filter((r) => r.round !== "Semi-Finals");
|
||||
|
||||
await expect(reseedAflSemiFinals(EVENT)).rejects.toThrow(
|
||||
"no AFL Elimination Finals / Semi-Finals matches to re-seed"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
/**
|
||||
* Advancing an AFL Wildcard Round winner into the Elimination Finals.
|
||||
*
|
||||
* The two winners are re-seeded by ladder position — 5th hosts the lower-ranked winner,
|
||||
* 6th the higher-ranked one — so the destination is not a fixed crossover from a given
|
||||
* Wildcard match, and results can be recorded in either order.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { AFL_10 } from "~/lib/bracket-templates";
|
||||
|
||||
interface MatchRow {
|
||||
id: string;
|
||||
scoringEventId: string;
|
||||
round: string;
|
||||
matchNumber: number;
|
||||
participant1Id: string | null;
|
||||
participant2Id: string | null;
|
||||
isComplete: boolean;
|
||||
winnerId: string | null;
|
||||
loserId: string | null;
|
||||
}
|
||||
|
||||
let rows: MatchRow[] = [];
|
||||
|
||||
/**
|
||||
* The literal values drizzle put in a where clause (`eq(col, value)`), which is all this
|
||||
* mock needs to tell one lookup from another — there is no query engine behind it.
|
||||
*/
|
||||
function whereValues(node: unknown, depth = 0): string[] {
|
||||
if (!node || depth > 10) return [];
|
||||
if (Array.isArray(node)) return node.flatMap((child) => whereValues(child, depth + 1));
|
||||
if (typeof node !== "object") return [];
|
||||
const obj = node as Record<string, unknown>;
|
||||
const own = typeof obj.value === "string" ? [obj.value] : [];
|
||||
return [...own, ...whereValues(obj.queryChunks, depth + 1)];
|
||||
}
|
||||
|
||||
const db = {
|
||||
query: {
|
||||
playoffMatches: {
|
||||
findFirst: vi.fn(({ where }: { where: unknown }) => {
|
||||
const values = whereValues(where);
|
||||
return Promise.resolve(rows.find((r) => values.includes(r.id)));
|
||||
}),
|
||||
findMany: vi.fn(({ where }: { where: unknown }) => {
|
||||
const values = whereValues(where);
|
||||
return Promise.resolve(
|
||||
rows
|
||||
.filter((r) => values.includes(r.scoringEventId) && values.includes(r.round))
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber)
|
||||
);
|
||||
}),
|
||||
},
|
||||
},
|
||||
update: vi.fn(() => ({
|
||||
set: (data: Partial<MatchRow>) => {
|
||||
const applyTo = (where: unknown) => {
|
||||
const values = whereValues(where);
|
||||
const target = rows.find((r) => values.includes(r.id));
|
||||
if (target) Object.assign(target, data);
|
||||
return target;
|
||||
};
|
||||
// Advancement writes through the query builder with and without .returning().
|
||||
return {
|
||||
where: (where: unknown) => {
|
||||
const applied = Promise.resolve([applyTo(where)]);
|
||||
return Object.assign(applied, { returning: () => applied });
|
||||
},
|
||||
};
|
||||
},
|
||||
})),
|
||||
// No rollback: the tests assert the writes that were attempted, in order.
|
||||
transaction: vi.fn((fn: (tx: typeof db) => Promise<unknown>) => fn(db)),
|
||||
};
|
||||
|
||||
vi.mock("~/database/context", () => ({ database: () => db }));
|
||||
|
||||
const { advanceWinnerTemplate, reseedAflEliminationFinals } = await import("../playoff-match");
|
||||
|
||||
const EVENT = "event-1";
|
||||
|
||||
/** Ladder seed n → participant id. */
|
||||
const seed = (n: number) => `seed-${n}`;
|
||||
|
||||
/** A freshly generated afl_10 Wildcard Round (7v10, 8v9) and Elimination Finals (5, 6). */
|
||||
function bracket(): MatchRow[] {
|
||||
const base = { scoringEventId: EVENT, isComplete: false, winnerId: null, loserId: null };
|
||||
return [
|
||||
{ ...base, id: "wc1", round: "Wildcard Round", matchNumber: 1, participant1Id: seed(7), participant2Id: seed(10) },
|
||||
{ ...base, id: "wc2", round: "Wildcard Round", matchNumber: 2, participant1Id: seed(8), participant2Id: seed(9) },
|
||||
{ ...base, id: "ef1", round: "Elimination Finals", matchNumber: 1, participant1Id: seed(5), participant2Id: null },
|
||||
{ ...base, id: "ef2", round: "Elimination Finals", matchNumber: 2, participant1Id: seed(6), participant2Id: null },
|
||||
];
|
||||
}
|
||||
|
||||
function row(id: string): MatchRow {
|
||||
const found = rows.find((r) => r.id === id);
|
||||
if (!found) throw new Error(`No such match ${id}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Record a Wildcard result the way setMatchWinner does, then advance it. */
|
||||
async function winWildcard(id: string, winnerId: string) {
|
||||
const match = row(id);
|
||||
match.winnerId = winnerId;
|
||||
match.loserId = match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
|
||||
match.isComplete = true;
|
||||
await advanceWinnerTemplate(id, winnerId, AFL_10);
|
||||
}
|
||||
|
||||
describe("AFL Wildcard Round advancement", () => {
|
||||
beforeEach(() => {
|
||||
rows = bracket();
|
||||
});
|
||||
|
||||
it("sends 5th the lower-ranked winner and 6th the higher-ranked one", async () => {
|
||||
await winWildcard("wc1", seed(7));
|
||||
await winWildcard("wc2", seed(8));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(8));
|
||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
||||
});
|
||||
|
||||
it("re-seeds when the lower seed wins through", async () => {
|
||||
// The reported bug: 10th beating 7th used to be crossed straight to 6th, leaving
|
||||
// 5th with the better survivor.
|
||||
await winWildcard("wc1", seed(10));
|
||||
await winWildcard("wc2", seed(8));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||
expect(row("ef2").participant2Id).toBe(seed(8));
|
||||
});
|
||||
|
||||
it("re-seeds a 9th-placed winner above a 10th-placed one", async () => {
|
||||
await winWildcard("wc1", seed(10));
|
||||
await winWildcard("wc2", seed(9));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||
expect(row("ef2").participant2Id).toBe(seed(9));
|
||||
});
|
||||
|
||||
it("places the same pairings whichever result is entered first", async () => {
|
||||
await winWildcard("wc2", seed(8));
|
||||
await winWildcard("wc1", seed(10));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||
expect(row("ef2").participant2Id).toBe(seed(8));
|
||||
});
|
||||
|
||||
it("places the 7v10 winner immediately, since its slot is settled either way", async () => {
|
||||
await winWildcard("wc1", seed(7));
|
||||
|
||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
||||
expect(row("ef1").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("holds an 8v9 winner back until the 7v10 game is decided", async () => {
|
||||
// 8th and 9th sit between 7th and 10th, so placing one now could need undoing.
|
||||
await winWildcard("wc2", seed(8));
|
||||
|
||||
expect(row("ef1").participant2Id).toBeNull();
|
||||
expect(row("ef2").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("does not disturb a winner it already placed", async () => {
|
||||
await winWildcard("wc1", seed(7));
|
||||
await winWildcard("wc2", seed(9));
|
||||
|
||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
||||
expect(row("ef1").participant2Id).toBe(seed(9));
|
||||
});
|
||||
|
||||
it("refuses to overwrite a slot already holding someone else", async () => {
|
||||
row("ef1").participant2Id = "stranger";
|
||||
|
||||
await expect(winWildcard("wc1", seed(10))).rejects.toThrow(/already filled/);
|
||||
expect(row("ef1").participant2Id).toBe("stranger");
|
||||
});
|
||||
|
||||
it("moves the winner when a recorded Wildcard result is corrected", async () => {
|
||||
await winWildcard("wc1", seed(7));
|
||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
||||
|
||||
// The result was wrong: 10th won. 7th must not be left alive in the other slot.
|
||||
await winWildcard("wc1", seed(10));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||
expect(row("ef2").participant2Id).toBeNull();
|
||||
});
|
||||
|
||||
it("re-seeds a pairing left behind by the old fixed crossover", async () => {
|
||||
// Pre-fix state: the 7v10 winner was crossed to 6th whatever its ladder position.
|
||||
row("wc1").winnerId = seed(10);
|
||||
row("wc1").loserId = seed(7);
|
||||
row("wc1").isComplete = true;
|
||||
row("ef2").participant2Id = seed(10);
|
||||
|
||||
await winWildcard("wc2", seed(8));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||
expect(row("ef2").participant2Id).toBe(seed(8));
|
||||
});
|
||||
|
||||
it("swaps both winners when re-resolving an already-placed pair", async () => {
|
||||
row("wc1").winnerId = seed(10);
|
||||
row("wc1").loserId = seed(7);
|
||||
row("wc1").isComplete = true;
|
||||
row("ef2").participant2Id = seed(10);
|
||||
row("ef1").participant2Id = seed(8);
|
||||
|
||||
await winWildcard("wc2", seed(8));
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||
expect(row("ef2").participant2Id).toBe(seed(8));
|
||||
});
|
||||
|
||||
it("refuses to re-seed an Elimination Final that has already been played", async () => {
|
||||
await winWildcard("wc1", seed(7));
|
||||
Object.assign(row("ef2"), { isComplete: true, winnerId: seed(6), loserId: seed(7) });
|
||||
|
||||
await expect(winWildcard("wc1", seed(10))).rejects.toThrow(/already has a recorded result/);
|
||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
||||
});
|
||||
|
||||
it("repairs an already-advanced bracket from the recorded results alone", async () => {
|
||||
// What scripts/fix-afl-wildcard-reseed.ts does: no new result, just the rows a
|
||||
// bracket advanced under the old fixed crossover left behind.
|
||||
Object.assign(row("wc1"), { isComplete: true, winnerId: seed(10), loserId: seed(7) });
|
||||
Object.assign(row("wc2"), { isComplete: true, winnerId: seed(8), loserId: seed(9) });
|
||||
row("ef2").participant2Id = seed(10);
|
||||
row("ef1").participant2Id = seed(8);
|
||||
|
||||
const reseed = await reseedAflEliminationFinals(EVENT);
|
||||
|
||||
expect(row("ef1").participant2Id).toBe(seed(10));
|
||||
expect(row("ef2").participant2Id).toBe(seed(8));
|
||||
expect(reseed.vacated.toSorted()).toEqual([1, 2]);
|
||||
expect(reseed.filled.toSorted((a, b) => a.matchNumber - b.matchNumber)).toEqual([
|
||||
{ matchNumber: 1, participantId: seed(10) },
|
||||
{ matchNumber: 2, participantId: seed(8) },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports no change when a repair run finds the pairings correct", async () => {
|
||||
await winWildcard("wc1", seed(7));
|
||||
await winWildcard("wc2", seed(8));
|
||||
db.transaction.mockClear();
|
||||
|
||||
const reseed = await reseedAflEliminationFinals(EVENT);
|
||||
|
||||
expect(reseed).toEqual({ vacated: [], filled: [] });
|
||||
expect(db.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an event with no AFL bracket rather than reporting nothing to do", async () => {
|
||||
await expect(reseedAflEliminationFinals("no-such-event")).rejects.toThrow(/no AFL Wildcard/);
|
||||
});
|
||||
|
||||
it("leaves the bracket alone when the pairings are already right", async () => {
|
||||
await winWildcard("wc1", seed(7));
|
||||
await winWildcard("wc2", seed(8));
|
||||
db.transaction.mockClear();
|
||||
|
||||
await winWildcard("wc2", seed(8));
|
||||
|
||||
expect(db.transaction).not.toHaveBeenCalled();
|
||||
expect(row("ef1").participant2Id).toBe(seed(8));
|
||||
expect(row("ef2").participant2Id).toBe(seed(7));
|
||||
});
|
||||
});
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
/**
|
||||
* Entry-floor scoring: points a bracket guarantees at seeding time.
|
||||
*
|
||||
* Some seedings lock in a scoring tier before a single match is played. The AFL
|
||||
* finals are the clearest case: a top-4 seed has the double chance, so losing the
|
||||
* Qualifying Final still leaves them a Semi-Final, and losing that is the 5th-6th
|
||||
* tier. Those teams must not sit on 0 fantasy points until their first game.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
interface MatchRow {
|
||||
round: string;
|
||||
participant1Id: string | null;
|
||||
participant2Id: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal db mock. applyBracketEntryFloors calls, in order:
|
||||
* 1. db.query.scoringEvents.findFirst → the event (for template + sportsSeasonId)
|
||||
* 2. db.query.playoffMatches.findMany → the bracket's match slots
|
||||
* 3. upsertParticipantResult per floored participant → findFirst + insert/update
|
||||
*/
|
||||
function makeDb(
|
||||
event: { bracketTemplateId: string | null; sportsSeasonId: string } | null,
|
||||
matches: MatchRow[],
|
||||
existingByParticipant: Record<string, { id: string; finalPosition: number; isPartialScore: boolean }> = {}
|
||||
) {
|
||||
const existingRows = Object.entries(existingByParticipant).map(([participantId, row]) => ({
|
||||
participantId,
|
||||
finalPosition: row.finalPosition,
|
||||
}));
|
||||
const insertedRows: Array<Record<string, unknown>> = [];
|
||||
const updatedRows: Array<Record<string, unknown>> = [];
|
||||
|
||||
return {
|
||||
db: {
|
||||
insert: vi.fn().mockReturnValue({
|
||||
values: vi.fn().mockImplementation((values: Record<string, unknown>) => {
|
||||
insertedRows.push(values);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
}),
|
||||
update: vi.fn().mockReturnValue({
|
||||
set: vi.fn().mockImplementation((values: Record<string, unknown>) => {
|
||||
updatedRows.push(values);
|
||||
return { where: vi.fn().mockResolvedValue(undefined) };
|
||||
}),
|
||||
}),
|
||||
query: {
|
||||
scoringEvents: { findFirst: vi.fn().mockResolvedValue(event) },
|
||||
playoffMatches: { findMany: vi.fn().mockResolvedValue(matches) },
|
||||
seasonParticipantResults: {
|
||||
// The pre-pass that stops a floor from downgrading an existing placement.
|
||||
findMany: vi.fn().mockResolvedValue(existingRows),
|
||||
findFirst: vi.fn().mockImplementation((args: { where?: unknown }) => {
|
||||
// Resolve by scanning the seeded map — the mock has no real query engine,
|
||||
// so tests that need an existing row use a single-participant bracket.
|
||||
void args;
|
||||
const only = Object.values(existingByParticipant)[0];
|
||||
return Promise.resolve(only);
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
insertedRows,
|
||||
updatedRows,
|
||||
};
|
||||
}
|
||||
|
||||
import { applyBracketEntryFloors, getBracketEntryFloor } from "../scoring-calculator";
|
||||
|
||||
/** The AFL bracket exactly as generateAFL10Bracket writes it: later rounds are TBD. */
|
||||
const AFL_BRACKET: MatchRow[] = [
|
||||
{ round: "Wildcard Round", participant1Id: "seed7", participant2Id: "seed10" },
|
||||
{ round: "Wildcard Round", participant1Id: "seed8", participant2Id: "seed9" },
|
||||
{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: "seed4" },
|
||||
{ round: "Qualifying Finals", participant1Id: "seed2", participant2Id: "seed3" },
|
||||
{ round: "Elimination Finals", participant1Id: "seed5", participant2Id: null },
|
||||
{ round: "Elimination Finals", participant1Id: "seed6", participant2Id: null },
|
||||
{ round: "Semi-Finals", participant1Id: null, participant2Id: null },
|
||||
{ round: "Semi-Finals", participant1Id: null, participant2Id: null },
|
||||
{ round: "Preliminary Finals", participant1Id: null, participant2Id: null },
|
||||
{ round: "Preliminary Finals", participant1Id: null, participant2Id: null },
|
||||
{ round: "Grand Final", participant1Id: null, participant2Id: null },
|
||||
];
|
||||
|
||||
describe("applyBracketEntryFloors", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("afl_10", () => {
|
||||
it("banks 5 for the top 4 and 7 for seeds 5-6, and nothing for the wildcard teams", async () => {
|
||||
const { db, insertedRows } = makeDb(
|
||||
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
||||
AFL_BRACKET
|
||||
);
|
||||
|
||||
const applied = await applyBracketEntryFloors("event-1", db);
|
||||
|
||||
expect(applied).toBe(6);
|
||||
const floors = Object.fromEntries(
|
||||
insertedRows.map((r) => [r.participantId as string, r.finalPosition as number])
|
||||
);
|
||||
expect(floors).toEqual({
|
||||
seed1: 5, seed2: 5, seed3: 5, seed4: 5, // double chance → 5th-6th tier
|
||||
seed5: 7, seed6: 7, // seeded into the Elimination Finals
|
||||
});
|
||||
// Seeds 7-10 lose the Wildcard Round for 0, so nothing is guaranteed yet.
|
||||
expect(floors).not.toHaveProperty("seed7");
|
||||
expect(floors).not.toHaveProperty("seed10");
|
||||
});
|
||||
|
||||
it("writes every floor as provisional so real results supersede it", async () => {
|
||||
const { db, insertedRows } = makeDb(
|
||||
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
||||
AFL_BRACKET
|
||||
);
|
||||
|
||||
await applyBracketEntryFloors("event-1", db);
|
||||
|
||||
expect(insertedRows.every((r) => r.isPartialScore === true)).toBe(true);
|
||||
expect(insertedRows.every((r) => r.sportsSeasonId === "ss-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves TBD slots alone — a Semi-Final nobody has reached grants nothing", async () => {
|
||||
const { db, insertedRows } = makeDb(
|
||||
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
||||
[{ round: "Semi-Finals", participant1Id: null, participant2Id: null }]
|
||||
);
|
||||
|
||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
||||
expect(insertedRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("never downgrades a better placement — a finalist regenerating stays a finalist", async () => {
|
||||
// clear-bracket → generate-bracket mid-tournament must not knock a team sitting
|
||||
// on a 2nd-place floor back down to their 5th-6th seeding floor.
|
||||
const { db, insertedRows, updatedRows } = makeDb(
|
||||
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
||||
[{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: null }],
|
||||
{ seed1: { id: "row-1", finalPosition: 2, isPartialScore: true } }
|
||||
);
|
||||
|
||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
||||
expect(insertedRows).toHaveLength(0);
|
||||
expect(updatedRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("treats position 0 as eliminated, not as a better placement", async () => {
|
||||
// A 0 means "missed the bracket". Re-seeding a team into the bracket must still
|
||||
// give them their floor rather than reading 0 as an unbeatable placement.
|
||||
const { db, updatedRows } = makeDb(
|
||||
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
||||
[{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: null }],
|
||||
{ seed1: { id: "row-1", finalPosition: 0, isPartialScore: true } }
|
||||
);
|
||||
|
||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(1);
|
||||
expect(updatedRows).toHaveLength(1);
|
||||
expect(updatedRows[0]).toMatchObject({ finalPosition: 5, isPartialScore: true });
|
||||
});
|
||||
|
||||
it("does not un-finalize a participant who already has a real result", async () => {
|
||||
// upsertParticipantResult's never-un-finalize guard: a finalized row must not be
|
||||
// dragged back to a provisional floor when the bracket is regenerated.
|
||||
const { db, insertedRows, updatedRows } = makeDb(
|
||||
{ bracketTemplateId: "afl_10", sportsSeasonId: "ss-1" },
|
||||
[{ round: "Qualifying Finals", participant1Id: "seed1", participant2Id: null }],
|
||||
{ seed1: { id: "row-1", finalPosition: 1, isPartialScore: false } }
|
||||
);
|
||||
|
||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
||||
expect(insertedRows).toHaveLength(0);
|
||||
expect(updatedRows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("other brackets", () => {
|
||||
it("is a no-op for an event with no bracket template", async () => {
|
||||
const { db, insertedRows } = makeDb(
|
||||
{ bracketTemplateId: null, sportsSeasonId: "ss-1" },
|
||||
AFL_BRACKET
|
||||
);
|
||||
|
||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
||||
expect(insertedRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is a no-op when the event does not exist", async () => {
|
||||
const { db } = makeDb(null, AFL_BRACKET);
|
||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
||||
});
|
||||
|
||||
it("grants nothing to an NBA bracket: every seeded round is non-scoring", async () => {
|
||||
const { db, insertedRows } = makeDb(
|
||||
{ bracketTemplateId: "nba_20", sportsSeasonId: "ss-1" },
|
||||
[
|
||||
{ round: "Play-In Round 1", participant1Id: "e7", participant2Id: "e8" },
|
||||
{ round: "First Round", participant1Id: "e1", participant2Id: null },
|
||||
]
|
||||
);
|
||||
|
||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(0);
|
||||
expect(insertedRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("grants the T5-8 tier to a simple_8 field: every entrant is already in a scoring round", async () => {
|
||||
const { db, insertedRows } = makeDb(
|
||||
{ bracketTemplateId: "simple_8", sportsSeasonId: "ss-1" },
|
||||
[{ round: "Quarterfinals", participant1Id: "a", participant2Id: "b" }]
|
||||
);
|
||||
|
||||
expect(await applyBracketEntryFloors("event-1", db)).toBe(2);
|
||||
expect(insertedRows.map((r) => r.finalPosition)).toEqual([5, 5]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBracketEntryFloor", () => {
|
||||
it("prefers a round's explicit entryFloor over its loser position", () => {
|
||||
// Qualifying Finals is non-scoring, so only the explicit entryFloor makes it pay.
|
||||
expect(getBracketEntryFloor("Qualifying Finals", "afl_10")).toBe(5);
|
||||
});
|
||||
|
||||
it("falls back to a scoring round's own loser position", () => {
|
||||
expect(getBracketEntryFloor("Quarterfinals", "simple_8")).toBe(5);
|
||||
expect(getBracketEntryFloor("Semifinals", "simple_8")).toBe(3);
|
||||
});
|
||||
|
||||
it("returns null for non-scoring rounds with no explicit floor", () => {
|
||||
expect(getBracketEntryFloor("Wildcard Round", "afl_10")).toBeNull();
|
||||
expect(getBracketEntryFloor("First Round", "nba_20")).toBeNull();
|
||||
expect(getBracketEntryFloor("Round of 64", "ncaa_68")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unknown rounds and templates", () => {
|
||||
expect(getBracketEntryFloor("Not A Round", "afl_10")).toBeNull();
|
||||
expect(getBracketEntryFloor("Quarterfinals", "not_a_template")).toBeNull();
|
||||
expect(getBracketEntryFloor("Quarterfinals", null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
getBracketTemplateIdForSportsSeason,
|
||||
getBracketTemplateIdsForSportsSeasons,
|
||||
} from "../bracket-template";
|
||||
|
||||
/**
|
||||
* A sports season can own several scoring events — a bracket plus schedule events, or a
|
||||
* re-created bracket alongside a stale one. Resolving the template from an arbitrary row
|
||||
* is not harmless: calculateBracketPoints falls back to the flat 5th–8th average when the
|
||||
* template id is null, so losing "llws_20" makes a team locked into 5th–6th and one
|
||||
* locked into 7th–8th both score 20.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Minimal db stub. Applies the same filter and ordering the real query does, so the
|
||||
* assertions exercise the helper's row-picking rather than re-stating the query.
|
||||
*/
|
||||
function makeDb(
|
||||
rows: Array<{ sportsSeasonId: string; bracketTemplateId: string | null; createdAt: Date }>
|
||||
) {
|
||||
const findMany = vi.fn(async () =>
|
||||
rows
|
||||
.filter((row) => row.bracketTemplateId !== null)
|
||||
.toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
|
||||
);
|
||||
return { db: { query: { scoringEvents: { findMany } } } as any, findMany };
|
||||
}
|
||||
|
||||
describe("getBracketTemplateIdForSportsSeason", () => {
|
||||
it("ignores a non-bracket event and returns the bracket event's template", async () => {
|
||||
const { db } = makeDb([
|
||||
{ sportsSeasonId: "ss1", bracketTemplateId: null, createdAt: new Date("2026-08-01") },
|
||||
{ sportsSeasonId: "ss1", bracketTemplateId: "llws_20", createdAt: new Date("2026-07-01") },
|
||||
]);
|
||||
|
||||
await expect(getBracketTemplateIdForSportsSeason("ss1", db)).resolves.toBe("llws_20");
|
||||
});
|
||||
|
||||
it("takes the most recent bracket event when a stale one is still around", async () => {
|
||||
const { db } = makeDb([
|
||||
{ sportsSeasonId: "ss1", bracketTemplateId: "simple_16", createdAt: new Date("2026-06-01") },
|
||||
{ sportsSeasonId: "ss1", bracketTemplateId: "llws_20", createdAt: new Date("2026-08-01") },
|
||||
]);
|
||||
|
||||
await expect(getBracketTemplateIdForSportsSeason("ss1", db)).resolves.toBe("llws_20");
|
||||
});
|
||||
|
||||
it("returns null when the season has no bracket event", async () => {
|
||||
const { db } = makeDb([
|
||||
{ sportsSeasonId: "ss1", bracketTemplateId: null, createdAt: new Date("2026-08-01") },
|
||||
]);
|
||||
|
||||
await expect(getBracketTemplateIdForSportsSeason("ss1", db)).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBracketTemplateIdsForSportsSeasons", () => {
|
||||
it("resolves each season independently in one query", async () => {
|
||||
const { db, findMany } = makeDb([
|
||||
{ sportsSeasonId: "ss1", bracketTemplateId: "llws_20", createdAt: new Date("2026-08-01") },
|
||||
{ sportsSeasonId: "ss2", bracketTemplateId: "afl_10", createdAt: new Date("2026-08-02") },
|
||||
{ sportsSeasonId: "ss3", bracketTemplateId: null, createdAt: new Date("2026-08-03") },
|
||||
]);
|
||||
|
||||
const resolved = await getBracketTemplateIdsForSportsSeasons(["ss1", "ss2", "ss3"], db);
|
||||
|
||||
expect(resolved.get("ss1")).toBe("llws_20");
|
||||
expect(resolved.get("ss2")).toBe("afl_10");
|
||||
expect(resolved.get("ss3")).toBeNull();
|
||||
expect(findMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("gives every requested season an entry so callers can cache the miss", async () => {
|
||||
const { db } = makeDb([]);
|
||||
|
||||
const resolved = await getBracketTemplateIdsForSportsSeasons(["ss1", "ss2"], db);
|
||||
|
||||
expect([...resolved.entries()]).toEqual([
|
||||
["ss1", null],
|
||||
["ss2", null],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not query at all for an empty season list", async () => {
|
||||
const { db, findMany } = makeDb([]);
|
||||
|
||||
await expect(getBracketTemplateIdsForSportsSeasons([], db)).resolves.toEqual(new Map());
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,17 +1,6 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// Stub only the cached-total recompute so resetCs2Event can be exercised without
|
||||
// a real DB; the rest of qualifying-points stays real for the pure-function tests.
|
||||
vi.mock("../qualifying-points", async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
recalculateParticipantQP: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { computeStage3ExitQP, resetCs2Event } from "../cs2-major-stage";
|
||||
import { calculateSplitQualifyingPoints, recalculateParticipantQP } from "../qualifying-points";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computeStage3ExitQP } from "../cs2-major-stage";
|
||||
import { calculateSplitQualifyingPoints } from "../qualifying-points";
|
||||
|
||||
function makeStage3QPConfig(): Map<number, number> {
|
||||
const config = new Map<number, number>();
|
||||
|
|
@ -268,57 +257,3 @@ describe("Champions Stage floor QP calculation", () => {
|
|||
expect(floorQP).toBeCloseTo((10 + 8 + 6 + 4) / 4); // = 7
|
||||
});
|
||||
});
|
||||
|
||||
describe("resetCs2Event", () => {
|
||||
/**
|
||||
* Build a mock db whose `transaction` runs its callback with the same handle
|
||||
* (so the deletes + recalcs run inline), capturing which tables were deleted.
|
||||
* The eventResults SELECT returns `resultRows`.
|
||||
*/
|
||||
function makeMockDb(resultRows: Array<{ participantId: string }>) {
|
||||
const deletedTables: unknown[] = [];
|
||||
const db: Record<string, unknown> = {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({ where: vi.fn().mockResolvedValue(resultRows) })),
|
||||
})),
|
||||
delete: vi.fn((table: unknown) => {
|
||||
deletedTables.push(table);
|
||||
return { where: vi.fn().mockResolvedValue(undefined) };
|
||||
}),
|
||||
};
|
||||
db.transaction = vi.fn(async (cb: (tx: unknown) => unknown) => cb(db));
|
||||
return { db, deletedTables };
|
||||
}
|
||||
|
||||
it("clears assignments + results and recomputes each distinct participant's QP total", async () => {
|
||||
vi.mocked(recalculateParticipantQP).mockClear();
|
||||
// Two participants, one duplicated across rows → recompute exactly twice.
|
||||
const { db, deletedTables } = makeMockDb([
|
||||
{ participantId: "p1" },
|
||||
{ participantId: "p1" },
|
||||
{ participantId: "p2" },
|
||||
]);
|
||||
|
||||
await resetCs2Event("event-1", "season-1", db as never);
|
||||
|
||||
// Both the stage-results table and the event-results table were deleted,
|
||||
// inside the transaction.
|
||||
expect((db as Record<string, ReturnType<typeof vi.fn>>).transaction).toHaveBeenCalledTimes(1);
|
||||
expect(deletedTables).toHaveLength(2);
|
||||
|
||||
const recalcCalls = vi.mocked(recalculateParticipantQP).mock.calls;
|
||||
expect(recalcCalls).toHaveLength(2);
|
||||
expect(recalcCalls.map((c) => c[0]).toSorted()).toEqual(["p1", "p2"]);
|
||||
for (const call of recalcCalls) expect(call[1]).toBe("season-1");
|
||||
});
|
||||
|
||||
it("still clears the event when there are no recorded results", async () => {
|
||||
vi.mocked(recalculateParticipantQP).mockClear();
|
||||
const { db, deletedTables } = makeMockDb([]);
|
||||
|
||||
await resetCs2Event("event-1", "season-1", db as never);
|
||||
|
||||
expect(deletedTables).toHaveLength(2);
|
||||
expect(recalculateParticipantQP).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,87 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock the database context before importing any model
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: vi.fn(),
|
||||
}));
|
||||
|
||||
import { database } from "~/database/context";
|
||||
import { batchUpsertParticipantSimulatorInputs } from "../simulator";
|
||||
|
||||
type SetPayload = Record<string, unknown>;
|
||||
|
||||
const conflictSetCalls: SetPayload[] = [];
|
||||
|
||||
const onConflictDoUpdate = vi.fn((arg: { set: SetPayload }) => {
|
||||
conflictSetCalls.push(arg.set);
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
|
||||
const tx = {
|
||||
insert: vi.fn(() => ({
|
||||
values: vi.fn(() => ({ onConflictDoUpdate })),
|
||||
})),
|
||||
};
|
||||
|
||||
const mockDb = {
|
||||
transaction: vi.fn((cb: (t: typeof tx) => Promise<unknown>) => cb(tx)),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
conflictSetCalls.length = 0;
|
||||
(database as ReturnType<typeof vi.fn>).mockReturnValue(mockDb);
|
||||
});
|
||||
|
||||
// Recursively flatten a Drizzle `sql` template into its static text so we can
|
||||
// assert how a conflict-update column is built (e.g. wrapped in COALESCE).
|
||||
function sqlToText(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
const chunks = (value as { queryChunks?: unknown[] }).queryChunks;
|
||||
if (Array.isArray(chunks)) return chunks.map(sqlToText).join("");
|
||||
const stringValue = (value as { value?: unknown }).value;
|
||||
if (Array.isArray(stringValue)) return stringValue.join("");
|
||||
if (typeof stringValue === "string") return stringValue;
|
||||
return "";
|
||||
}
|
||||
|
||||
describe("batchUpsertParticipantSimulatorInputs", () => {
|
||||
it("updates only the columns it is given, preserving the rest (non-destructive)", async () => {
|
||||
await batchUpsertParticipantSimulatorInputs([
|
||||
// Odds-only import (e.g. from the bulk futures paste): no Elo/rating supplied.
|
||||
{ participantId: "team-1", sportsSeasonId: "season-1", sourceOdds: 550 },
|
||||
]);
|
||||
|
||||
// Runs in a transaction, writing the simulator-inputs row first then the
|
||||
// legacy EV bridge row.
|
||||
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(conflictSetCalls.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const simulatorSet = conflictSetCalls[0];
|
||||
// Every input column participates in the conflict update so partial pastes
|
||||
// can target any field...
|
||||
for (const column of ["sourceOdds", "sourceElo", "worldRanking", "rating", "seed", "region"]) {
|
||||
expect(simulatorSet).toHaveProperty(column);
|
||||
}
|
||||
// ...but each is COALESCE-wrapped, so a null incoming value keeps the stored
|
||||
// one instead of clobbering it. A bare `excluded.*` here would be the bug.
|
||||
const sourceEloSql = sqlToText(simulatorSet.sourceElo).toLowerCase();
|
||||
expect(sourceEloSql).toContain("coalesce");
|
||||
const ratingSql = sqlToText(simulatorSet.rating).toLowerCase();
|
||||
expect(ratingSql).toContain("coalesce");
|
||||
|
||||
// Metadata must drop the per-column method flag whenever that column gets a
|
||||
// fresh direct value, so a stale "generated" flag can't hide a newly entered
|
||||
// Elo/rating. Blindly preserving metadata (e.g. COALESCE) would be the bug.
|
||||
const metadataSql = sqlToText(simulatorSet.metadata).toLowerCase();
|
||||
expect(metadataSql).toContain("sourceelomethod");
|
||||
expect(metadataSql).toContain("ratingmethod");
|
||||
expect(metadataSql).toContain("excluded.source_elo");
|
||||
expect(metadataSql).toContain("excluded.rating");
|
||||
});
|
||||
|
||||
it("is a no-op when given no inputs", async () => {
|
||||
await batchUpsertParticipantSimulatorInputs([]);
|
||||
expect(mockDb.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,481 +0,0 @@
|
|||
/**
|
||||
* LLWS 20-Team Double-Elimination Bracket Tests
|
||||
*
|
||||
* Verifies the llws_20 template against the official 2026 LLBWS bracket
|
||||
* (Williamsport, Aug 19–30). The PDF numbers its games 1–38; those numbers appear
|
||||
* throughout as `G<n>` so the routing can be checked against the printed bracket.
|
||||
*
|
||||
* The critical property under test is the double-elimination loser routing: a loss in
|
||||
* the winners bracket drops a team into the elimination bracket at a specific slot,
|
||||
* while a loss in the elimination bracket is final.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
LLWS_20,
|
||||
getScoringRoundType,
|
||||
llwsMatchNumber,
|
||||
llwsSideAndLocal,
|
||||
} from "~/lib/bracket-templates";
|
||||
import {
|
||||
doesLoserAdvance,
|
||||
generateBracketFromTemplate,
|
||||
resolveLLWSAdvancement,
|
||||
} from "../playoff-match";
|
||||
import {
|
||||
calculateBracketPoints,
|
||||
calculateAveragedPoints,
|
||||
type ScoringRules,
|
||||
} from "../scoring-rules";
|
||||
import {
|
||||
GAME_TO_MATCH,
|
||||
EXPECTED_SLOTS,
|
||||
gameNumberFor,
|
||||
required,
|
||||
destinationGame,
|
||||
} from "~/test/fixtures/llws-bracket";
|
||||
|
||||
// generateBracketFromTemplate's only DB touch for llws_20 is the bulk insert, so a
|
||||
// minimal stub is enough to capture the generated rows.
|
||||
const insertedRows: Record<string, unknown>[] = [];
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: () => ({
|
||||
insert: () => ({
|
||||
values: (rows: Record<string, unknown>[]) => ({
|
||||
returning: async () => {
|
||||
insertedRows.push(...rows);
|
||||
return rows;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
const DEFAULT_SCORING: ScoringRules = {
|
||||
pointsFor1st: 100,
|
||||
pointsFor2nd: 70,
|
||||
pointsFor3rd: 50,
|
||||
pointsFor4th: 40,
|
||||
pointsFor5th: 25,
|
||||
pointsFor6th: 20,
|
||||
pointsFor7th: 15,
|
||||
pointsFor8th: 10,
|
||||
};
|
||||
|
||||
describe("LLWS 20 Bracket Template", () => {
|
||||
describe("Template structure", () => {
|
||||
it("has correct identity and size", () => {
|
||||
expect(LLWS_20.id).toBe("llws_20");
|
||||
expect(LLWS_20.totalTeams).toBe(20);
|
||||
expect(LLWS_20.scoringStartsAtRound).toBe("Winners Final");
|
||||
});
|
||||
|
||||
it("has 12 rounds totalling 38 matches", () => {
|
||||
expect(LLWS_20.rounds).toHaveLength(12);
|
||||
const total = LLWS_20.rounds.reduce((sum, r) => sum + r.matchCount, 0);
|
||||
expect(total).toBe(38);
|
||||
});
|
||||
|
||||
it("has the expected match count per round", () => {
|
||||
const counts = Object.fromEntries(
|
||||
LLWS_20.rounds.map((r) => [r.name, r.matchCount])
|
||||
);
|
||||
expect(counts).toEqual({
|
||||
"Opening Round": 8,
|
||||
"Winners Round 2": 4,
|
||||
"Elimination Round 1": 4,
|
||||
"Winners Semifinals": 4,
|
||||
"Elimination Round 2": 4,
|
||||
"Elimination Round 3": 4,
|
||||
"Winners Final": 2,
|
||||
"Elimination Round 4": 2,
|
||||
"Elimination Final": 2,
|
||||
"Bracket Championship": 2,
|
||||
"Consolation Third Place": 1,
|
||||
"World Championship": 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks exactly the point-awarding rounds as scoring", () => {
|
||||
const scoring = LLWS_20.rounds.filter((r) => r.isScoring).map((r) => r.name);
|
||||
expect(scoring).toEqual([
|
||||
"Winners Final",
|
||||
"Elimination Round 4",
|
||||
"Elimination Final",
|
||||
"Bracket Championship",
|
||||
"Consolation Third Place",
|
||||
"World Championship",
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists rounds in chronological order", () => {
|
||||
// Elimination Round 1 (Aug 22) is played before Winners Semifinals (Aug 23).
|
||||
const names = LLWS_20.rounds.map((r) => r.name);
|
||||
expect(names.indexOf("Elimination Round 1")).toBeLessThan(
|
||||
names.indexOf("Winners Semifinals")
|
||||
);
|
||||
expect(names.indexOf("Winners Final")).toBeLessThan(
|
||||
names.indexOf("Elimination Final")
|
||||
);
|
||||
});
|
||||
|
||||
it("gives elimination-bracket winners a floor matching their real worst case", () => {
|
||||
const byName = (n: string) => LLWS_20.rounds.find((r) => r.name === n);
|
||||
// Winning Elim R3 only guarantees 7th (a loss in Elim R4 is the 7–8 tier),
|
||||
// so the engine's default floor of 5 would overstate it.
|
||||
expect(byName("Elimination Round 3")?.nonScoringWinnerFloor).toBe(7);
|
||||
// Reaching the Winners Final guarantees 5th at worst.
|
||||
expect(byName("Winners Semifinals")?.nonScoringWinnerFloor).toBe(5);
|
||||
// Nothing is guaranteed earlier than that.
|
||||
expect(byName("Opening Round")?.nonScoringWinnerFloor).toBeNull();
|
||||
expect(byName("Winners Round 2")?.nonScoringWinnerFloor).toBeNull();
|
||||
expect(byName("Elimination Round 1")?.nonScoringWinnerFloor).toBeNull();
|
||||
expect(byName("Elimination Round 2")?.nonScoringWinnerFloor).toBeNull();
|
||||
});
|
||||
|
||||
it("has 20 participant labels", () => {
|
||||
expect(LLWS_20.participantLabels).toHaveLength(20);
|
||||
});
|
||||
|
||||
it("splits display into U.S., International and Championship phases", () => {
|
||||
expect(LLWS_20.phases?.map((p) => p.name)).toEqual([
|
||||
"United States",
|
||||
"International",
|
||||
"Championship",
|
||||
]);
|
||||
});
|
||||
|
||||
it("assigns every match to exactly one phase group", () => {
|
||||
const claimed = new Map<string, number>();
|
||||
for (const phase of LLWS_20.phases ?? []) {
|
||||
for (const group of phase.groups ?? []) {
|
||||
for (const [round, numbers] of Object.entries(group.roundMatchNumbers)) {
|
||||
for (const n of numbers) {
|
||||
const key = `${round}#${n}`;
|
||||
claimed.set(key, (claimed.get(key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every per-side match claimed exactly once (36 games; the 2 finals live in
|
||||
// the Championship phase's plain round list, not in a group).
|
||||
expect(claimed.size).toBe(36);
|
||||
expect([...claimed.values()].every((c) => c === 1)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Bracket generation", () => {
|
||||
const PARTICIPANTS = Array.from({ length: 20 }, (_, i) => `team-${i}`);
|
||||
|
||||
async function generate() {
|
||||
insertedRows.length = 0;
|
||||
await generateBracketFromTemplate("event-1", "llws_20", PARTICIPANTS);
|
||||
return insertedRows.map((r) => ({
|
||||
round: r.round as string,
|
||||
matchNumber: r.matchNumber as number,
|
||||
participant1Id: (r.participant1Id ?? null) as string | null,
|
||||
participant2Id: (r.participant2Id ?? null) as string | null,
|
||||
isScoring: r.isScoring as boolean,
|
||||
}));
|
||||
}
|
||||
|
||||
it("creates all 38 matches", async () => {
|
||||
const rows = await generate();
|
||||
expect(rows).toHaveLength(38);
|
||||
});
|
||||
|
||||
it("creates the right number of matches per round", async () => {
|
||||
const rows = await generate();
|
||||
for (const round of LLWS_20.rounds) {
|
||||
expect(
|
||||
rows.filter((r) => r.round === round.name),
|
||||
`${round.name} match count`
|
||||
).toHaveLength(round.matchCount);
|
||||
}
|
||||
});
|
||||
|
||||
it("numbers matches 1..n within each round", async () => {
|
||||
const rows = await generate();
|
||||
for (const round of LLWS_20.rounds) {
|
||||
const numbers = rows
|
||||
.filter((r) => r.round === round.name)
|
||||
.map((r) => r.matchNumber)
|
||||
.toSorted((a, b) => a - b);
|
||||
expect(numbers).toEqual(
|
||||
Array.from({ length: round.matchCount }, (_, i) => i + 1)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("seeds the Opening Round two teams at a time, U.S. then International", async () => {
|
||||
const rows = await generate();
|
||||
const opening = rows
|
||||
.filter((r) => r.round === "Opening Round")
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber);
|
||||
// U.S. slots 0–7 fill matches 1–4; International slots 10–17 fill matches 5–8.
|
||||
expect(opening.map((m) => [m.participant1Id, m.participant2Id])).toEqual([
|
||||
["team-0", "team-1"],
|
||||
["team-2", "team-3"],
|
||||
["team-4", "team-5"],
|
||||
["team-6", "team-7"],
|
||||
["team-10", "team-11"],
|
||||
["team-12", "team-13"],
|
||||
["team-14", "team-15"],
|
||||
["team-16", "team-17"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("seats the four bye teams in Winners Round 2 awaiting an opponent", async () => {
|
||||
const rows = await generate();
|
||||
const wr2 = rows
|
||||
.filter((r) => r.round === "Winners Round 2")
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber);
|
||||
expect(wr2.map((m) => [m.participant1Id, m.participant2Id])).toEqual([
|
||||
["team-8", null],
|
||||
["team-9", null],
|
||||
["team-18", null],
|
||||
["team-19", null],
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses each participant exactly once and leaves every other slot empty", async () => {
|
||||
const rows = await generate();
|
||||
const seeded = rows
|
||||
.flatMap((r) => [r.participant1Id, r.participant2Id])
|
||||
.filter((id): id is string => id !== null);
|
||||
expect(seeded).toHaveLength(20);
|
||||
expect(new Set(seeded).size).toBe(20);
|
||||
expect(new Set(seeded)).toEqual(new Set(PARTICIPANTS));
|
||||
});
|
||||
|
||||
it("stamps isScoring from the template", async () => {
|
||||
const rows = await generate();
|
||||
for (const round of LLWS_20.rounds) {
|
||||
for (const row of rows.filter((r) => r.round === round.name)) {
|
||||
expect(row.isScoring, `${round.name} #${row.matchNumber}`).toBe(round.isScoring);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a participant count other than 20", async () => {
|
||||
await expect(
|
||||
generateBracketFromTemplate("event-1", "llws_20", PARTICIPANTS.slice(0, 19))
|
||||
).rejects.toThrow(/requires 20 participants/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Side / match-number mapping", () => {
|
||||
it("round-trips every match number through side-local form", () => {
|
||||
for (const round of LLWS_20.rounds) {
|
||||
if (round.matchCount === 1) continue; // shared finals have no side
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const { side, localMatch } = llwsSideAndLocal(round.name, n);
|
||||
expect(llwsMatchNumber(round.name, side, localMatch)).toBe(n);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("puts U.S. matches in the low half and International in the high half", () => {
|
||||
expect(llwsSideAndLocal("Opening Round", 4).side).toBe(0);
|
||||
expect(llwsSideAndLocal("Opening Round", 5).side).toBe(1);
|
||||
expect(llwsSideAndLocal("Winners Semifinals", 2).side).toBe(0);
|
||||
expect(llwsSideAndLocal("Winners Semifinals", 3).side).toBe(1);
|
||||
expect(llwsSideAndLocal("Winners Final", 1).side).toBe(0);
|
||||
expect(llwsSideAndLocal("Winners Final", 2).side).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Advancement matches the official bracket", () => {
|
||||
/**
|
||||
* Replay the whole tournament through resolveLLWSAdvancement and record which
|
||||
* feed label ends up in each slot, then compare against the printed bracket.
|
||||
*/
|
||||
const actualSlots: Record<number, [string | null, string | null]> = {};
|
||||
for (const game of Object.keys(EXPECTED_SLOTS)) {
|
||||
actualSlots[Number(game)] = [null, null];
|
||||
}
|
||||
|
||||
for (const [gameStr, { round, matchNumber }] of Object.entries(GAME_TO_MATCH)) {
|
||||
const game = Number(gameStr);
|
||||
const { winner, loser } = resolveLLWSAdvancement(round, matchNumber);
|
||||
for (const [dest, label] of [
|
||||
[winner, `W${game}`],
|
||||
[loser, `L${game}`],
|
||||
] as const) {
|
||||
if (!dest) continue;
|
||||
const targetGame = gameNumberFor(dest.round, dest.matchNumber);
|
||||
const slotIndex = dest.slot === "participant1Id" ? 0 : 1;
|
||||
actualSlots[targetGame][slotIndex] = label;
|
||||
}
|
||||
}
|
||||
|
||||
it.each(Object.keys(EXPECTED_SLOTS).map(Number).toSorted((a, b) => a - b))(
|
||||
"Game %i has the printed participants",
|
||||
(game) => {
|
||||
expect(actualSlots[game]).toEqual(EXPECTED_SLOTS[game]);
|
||||
}
|
||||
);
|
||||
|
||||
it("fills every slot in the bracket exactly once", () => {
|
||||
// 38 games × 2 slots = 76. 20 are seeded directly (16 opening teams + 4 byes),
|
||||
// leaving 56 to be filled by advancement.
|
||||
const filled = Object.values(actualSlots)
|
||||
.flat()
|
||||
.filter((s) => s !== null).length;
|
||||
expect(filled).toBe(56);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Double-elimination loser routing", () => {
|
||||
it("routes every winners-bracket loser into the elimination bracket", () => {
|
||||
const winnersRounds = [
|
||||
"Opening Round",
|
||||
"Winners Round 2",
|
||||
"Winners Semifinals",
|
||||
"Winners Final",
|
||||
];
|
||||
for (const roundName of winnersRounds) {
|
||||
const round = LLWS_20.rounds.find((r) => r.name === roundName);
|
||||
if (!round) throw new Error(`missing round ${roundName}`);
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const { loser } = resolveLLWSAdvancement(roundName, n);
|
||||
expect(loser, `${roundName} #${n} loser should advance`).not.toBeNull();
|
||||
expect(loser?.round.startsWith("Elimination")).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("eliminates every elimination-bracket loser", () => {
|
||||
const elimRounds = [
|
||||
"Elimination Round 1",
|
||||
"Elimination Round 2",
|
||||
"Elimination Round 3",
|
||||
"Elimination Round 4",
|
||||
"Elimination Final",
|
||||
];
|
||||
for (const roundName of elimRounds) {
|
||||
const round = LLWS_20.rounds.find((r) => r.name === roundName);
|
||||
if (!round) throw new Error(`missing round ${roundName}`);
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const { loser } = resolveLLWSAdvancement(roundName, n);
|
||||
expect(loser, `${roundName} #${n} loser should be out`).toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the winners-bracket final loser alive via the Elimination Final", () => {
|
||||
// G30 (U.S. Winners Final) loser → G34, not out. This is the defining
|
||||
// double-elimination behavior: a first loss never eliminates.
|
||||
const { winner, loser } = resolveLLWSAdvancement("Winners Final", 1);
|
||||
expect(destinationGame(loser)).toBe(34);
|
||||
expect(destinationGame(winner)).toBe(36);
|
||||
});
|
||||
|
||||
it("sends the side-championship loser to the consolation game, not out", () => {
|
||||
// No "if necessary" rematch: the winners-bracket champion that loses G36 is
|
||||
// done in the bracket, but still plays G37 for 3rd/4th.
|
||||
const us = resolveLLWSAdvancement("Bracket Championship", 1);
|
||||
expect(destinationGame(us.winner)).toBe(38);
|
||||
expect(destinationGame(us.loser)).toBe(37);
|
||||
expect(required(us.winner).slot).toBe("participant1Id");
|
||||
expect(required(us.loser).slot).toBe("participant1Id");
|
||||
|
||||
const intl = resolveLLWSAdvancement("Bracket Championship", 2);
|
||||
expect(required(intl.winner).slot).toBe("participant2Id");
|
||||
expect(required(intl.loser).slot).toBe("participant2Id");
|
||||
});
|
||||
|
||||
it("flags winners-bracket losers as advancing so they are not marked eliminated", () => {
|
||||
// doesLoserAdvance is what stops the scoring engine writing a 0-point
|
||||
// elimination (and announcing a knockout) for a team that is still alive.
|
||||
// Winners Final and Bracket Championship are scoring rounds and are covered
|
||||
// by loserIsPartial instead, so they are deliberately not listed here.
|
||||
for (const round of ["Opening Round", "Winners Round 2", "Winners Semifinals"]) {
|
||||
expect(doesLoserAdvance(round, 1, "llws_20"), round).toBe(true);
|
||||
}
|
||||
for (const round of [
|
||||
"Elimination Round 1",
|
||||
"Elimination Round 2",
|
||||
"Elimination Round 3",
|
||||
"Elimination Round 4",
|
||||
"Elimination Final",
|
||||
]) {
|
||||
expect(doesLoserAdvance(round, 1, "llws_20"), round).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not apply LLWS loser routing to other templates", () => {
|
||||
expect(doesLoserAdvance("Opening Round", 1, "ncaa_68")).toBe(false);
|
||||
expect(doesLoserAdvance("Winners Semifinals", 1, "")).toBe(false);
|
||||
});
|
||||
|
||||
it("advances nobody out of the two final games", () => {
|
||||
for (const round of ["Consolation Third Place", "World Championship"]) {
|
||||
expect(resolveLLWSAdvancement(round, 1)).toEqual({ winner: null, loser: null });
|
||||
}
|
||||
});
|
||||
|
||||
it("never crosses a team between the U.S. and International sides", () => {
|
||||
for (const round of LLWS_20.rounds) {
|
||||
if (round.name === "Bracket Championship") continue; // the crossover point
|
||||
if (round.matchCount === 1) continue;
|
||||
for (let n = 1; n <= round.matchCount; n++) {
|
||||
const { side } = llwsSideAndLocal(round.name, n);
|
||||
const { winner, loser } = resolveLLWSAdvancement(round.name, n);
|
||||
for (const dest of [winner, loser]) {
|
||||
if (!dest) continue;
|
||||
const destRound = LLWS_20.rounds.find((r) => r.name === dest.round);
|
||||
if (!destRound || destRound.matchCount === 1) continue;
|
||||
expect(llwsSideAndLocal(dest.round, dest.matchNumber).side).toBe(side);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Placement tiers", () => {
|
||||
it("classifies scoring rounds correctly", () => {
|
||||
expect(getScoringRoundType("Elimination Round 4", LLWS_20)).toBe("quarterfinals");
|
||||
expect(getScoringRoundType("Elimination Final", LLWS_20)).toBe("quarterfinals");
|
||||
expect(getScoringRoundType("Bracket Championship", LLWS_20)).toBe("semifinals");
|
||||
expect(getScoringRoundType("World Championship", LLWS_20)).toBe("finals");
|
||||
// Nobody is eliminated in the Winners Final — the loser drops to the
|
||||
// elimination bracket — so it has no placement tier.
|
||||
expect(getScoringRoundType("Winners Final", LLWS_20)).toBeNull();
|
||||
});
|
||||
|
||||
it("pays 3rd and 4th distinctly (there is a real consolation game)", () => {
|
||||
expect(calculateBracketPoints(3, DEFAULT_SCORING, "llws_20")).toBe(50);
|
||||
expect(calculateBracketPoints(4, DEFAULT_SCORING, "llws_20")).toBe(40);
|
||||
});
|
||||
|
||||
it("splits 5–8 into two two-team tiers", () => {
|
||||
const upper = calculateAveragedPoints([5, 6], DEFAULT_SCORING); // (25+20)/2
|
||||
const lower = calculateAveragedPoints([7, 8], DEFAULT_SCORING); // (15+10)/2
|
||||
expect(calculateBracketPoints(5, DEFAULT_SCORING, "llws_20")).toBe(upper);
|
||||
expect(calculateBracketPoints(6, DEFAULT_SCORING, "llws_20")).toBe(upper);
|
||||
expect(calculateBracketPoints(7, DEFAULT_SCORING, "llws_20")).toBe(lower);
|
||||
expect(calculateBracketPoints(8, DEFAULT_SCORING, "llws_20")).toBe(lower);
|
||||
// Surviving Elimination Round 4 is worth more than losing it.
|
||||
expect(upper).toBeGreaterThan(lower);
|
||||
});
|
||||
|
||||
it("awards nothing below 8th", () => {
|
||||
// The 12 teams knocked out in Elimination Rounds 1–3 finish 9th–20th.
|
||||
expect(calculateBracketPoints(9, DEFAULT_SCORING, "llws_20")).toBe(0);
|
||||
expect(calculateBracketPoints(0, DEFAULT_SCORING, "llws_20")).toBe(0);
|
||||
});
|
||||
|
||||
it("has exactly 8 teams alive when the first scoring elimination game is played", () => {
|
||||
// Elimination Round 4 is the 7th–8th tier, so the field must be 8 at that point:
|
||||
// per side the Winners Final winner, the Winners Final loser, and the two
|
||||
// Elimination Round 3 winners.
|
||||
const eliminatedBeforeElimR4 =
|
||||
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 1")?.matchCount ?? 0) +
|
||||
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 2")?.matchCount ?? 0) +
|
||||
(LLWS_20.rounds.find((r) => r.name === "Elimination Round 3")?.matchCount ?? 0);
|
||||
expect(eliminatedBeforeElimR4).toBe(12);
|
||||
expect(LLWS_20.totalTeams - eliminatedBeforeElimR4).toBe(8);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// populateBracketFromDraw upserts a full draw into playoff_matches and reports
|
||||
// which losers' matches *transitioned to complete on this run* — the signal used
|
||||
// to announce knockouts once, idempotently across re-syncs.
|
||||
|
||||
const existingRows: Array<Record<string, unknown>> = [];
|
||||
|
||||
const mockDb = {
|
||||
query: {
|
||||
playoffMatches: {
|
||||
findMany: vi.fn(async () => existingRows),
|
||||
},
|
||||
},
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
|
||||
})),
|
||||
insert: vi.fn(() => ({
|
||||
values: vi.fn(() => ({ returning: vi.fn().mockResolvedValue([]) })),
|
||||
})),
|
||||
};
|
||||
|
||||
vi.mock("~/database/context", () => ({ database: () => mockDb }));
|
||||
|
||||
import { populateBracketFromDraw, type ResolvedDrawMatch } from "../playoff-match";
|
||||
|
||||
const EVENT_ID = "ev-1";
|
||||
|
||||
function match(overrides: Partial<ResolvedDrawMatch> = {}): ResolvedDrawMatch {
|
||||
return {
|
||||
externalMatchId: "m-1",
|
||||
round: "Round of 64",
|
||||
matchNumber: 1,
|
||||
participant1Id: "winner",
|
||||
participant2Id: "loser",
|
||||
winnerId: "winner",
|
||||
loserId: "loser",
|
||||
isScoring: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
existingRows.length = 0;
|
||||
});
|
||||
|
||||
describe("populateBracketFromDraw newlyDecidedLoserIds", () => {
|
||||
it("reports the loser of a brand-new completed match", async () => {
|
||||
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
|
||||
match({ externalMatchId: "m-1", loserId: "mensik" }),
|
||||
]);
|
||||
|
||||
expect(newlyDecidedLoserIds).toEqual(["mensik"]);
|
||||
});
|
||||
|
||||
it("reports the loser of an existing match that just reached completion", async () => {
|
||||
existingRows.push({
|
||||
id: "row-1",
|
||||
externalMatchId: "m-1",
|
||||
isComplete: false,
|
||||
loserId: null,
|
||||
winnerId: null,
|
||||
});
|
||||
|
||||
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
|
||||
match({ externalMatchId: "m-1", loserId: "mensik" }),
|
||||
]);
|
||||
|
||||
expect(newlyDecidedLoserIds).toEqual(["mensik"]);
|
||||
});
|
||||
|
||||
it("reports nothing on an idempotent re-sync of an already-complete match", async () => {
|
||||
existingRows.push({
|
||||
id: "row-1",
|
||||
externalMatchId: "m-1",
|
||||
isComplete: true,
|
||||
loserId: "mensik",
|
||||
winnerId: "winner",
|
||||
});
|
||||
|
||||
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
|
||||
match({ externalMatchId: "m-1", loserId: "mensik" }),
|
||||
]);
|
||||
|
||||
expect(newlyDecidedLoserIds).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores incomplete matches (no winner/loser yet)", async () => {
|
||||
const { newlyDecidedLoserIds, completed } = await populateBracketFromDraw(EVENT_ID, [
|
||||
match({ externalMatchId: "m-2", winnerId: null, loserId: null }),
|
||||
]);
|
||||
|
||||
expect(newlyDecidedLoserIds).toEqual([]);
|
||||
expect(completed).toBe(0);
|
||||
});
|
||||
|
||||
it("collects only the newly-decided losers in a mixed batch", async () => {
|
||||
existingRows.push(
|
||||
{ id: "row-1", externalMatchId: "m-1", isComplete: true, loserId: "old", winnerId: "w1" },
|
||||
{ id: "row-2", externalMatchId: "m-2", isComplete: false, loserId: null, winnerId: null },
|
||||
);
|
||||
|
||||
const { newlyDecidedLoserIds } = await populateBracketFromDraw(EVENT_ID, [
|
||||
match({ externalMatchId: "m-1", loserId: "old" }), // already complete → skip
|
||||
match({ externalMatchId: "m-2", loserId: "freshly-out" }), // transitioned → include
|
||||
match({ externalMatchId: "m-3", loserId: "brand-new-out" }), // new complete → include
|
||||
match({ externalMatchId: "m-4", winnerId: null, loserId: null }), // incomplete → skip
|
||||
]);
|
||||
|
||||
expect(newlyDecidedLoserIds.toSorted()).toEqual(["brand-new-out", "freshly-out"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -190,26 +190,12 @@ describe("processMatchResult", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("AFL Wildcard Round: loser=0, winner gets T7 floor (a Wildcard win only buys an Elimination Final)", async () => {
|
||||
// The generic "entering a scoring round ⇒ top-8" default would bank 5 here,
|
||||
// over-awarding the 5th-6th tier to a team whose next loss is the 7th-8th tier.
|
||||
it("AFL Wildcard Round: loser=0, winner gets T5 floor (feeds into Elimination Finals = scoring)", async () => {
|
||||
const { db, insertedRows } = makeDb();
|
||||
await processMatchResult({ ...BASE, bracketTemplateId: "afl_10", round: "Wildcard Round", isScoring: false }, db);
|
||||
expect(insertedRows).toHaveLength(2);
|
||||
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false });
|
||||
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 7, isPartialScore: true });
|
||||
});
|
||||
|
||||
it("AFL Qualifying Finals: winner gets T3 floor (bye to a Preliminary Final), loser holds their entry floor", async () => {
|
||||
const { db, insertedRows } = makeDb();
|
||||
await processMatchResult(
|
||||
{ ...BASE, bracketTemplateId: "afl_10", round: "Qualifying Finals", isScoring: false, loserAdvances: true },
|
||||
db
|
||||
);
|
||||
// Only the winner is written: the loser still has a Semi-Final, so their
|
||||
// seeding-derived floor of 5 stands untouched.
|
||||
expect(insertedRows).toHaveLength(1);
|
||||
expect(insertedRows[0]).toMatchObject({ participantId: "winner-1", finalPosition: 3, isPartialScore: true });
|
||||
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 5, isPartialScore: true });
|
||||
});
|
||||
|
||||
describe("NBA Play-In loserAdvances=true (7v8 game)", () => {
|
||||
|
|
@ -319,22 +305,6 @@ describe("processMatchResult", () => {
|
|||
expect(updateProbabilitiesAfterResult).toHaveBeenCalledWith("ss-1", true);
|
||||
});
|
||||
|
||||
it("skips only the probability refresh when asked, still announcing", async () => {
|
||||
// For a caller scoring several matches in a loop: the refresh is season-wide and, for a
|
||||
// bracket-aware sport, a full Monte Carlo run, so it belongs once after the loop rather
|
||||
// than once per match. Standings and the announcement still happen per match.
|
||||
const { db } = makeDb();
|
||||
|
||||
await processMatchResult(
|
||||
{ ...BASE, round: "Quarterfinals", isScoring: true, skipProbabilities: true },
|
||||
db
|
||||
);
|
||||
|
||||
expect(updateProbabilitiesAfterResult).not.toHaveBeenCalled();
|
||||
// recalculateAffectedLeagues still ran: it is the only thing that reads seasonSports.
|
||||
expect(db.query.seasonSports.findMany).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not throw even if probability update fails", async () => {
|
||||
(updateProbabilitiesAfterResult as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error("network error")
|
||||
|
|
@ -429,7 +399,6 @@ describe("isLoserNotifiable", () => {
|
|||
});
|
||||
});
|
||||
|
||||
|
||||
describe("processPlayoffEvent - NBA Play-In Round 1 loserAdvances fix", () => {
|
||||
function makePlayInDb(matches: object[]) {
|
||||
const insertedRows: Array<{ participantId: string; finalPosition: number; isPartialScore: boolean }> = [];
|
||||
|
|
|
|||
|
|
@ -1,251 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
deriveBracketQualifyingStates,
|
||||
getRoundConfig,
|
||||
getGuaranteedMinimumPosition,
|
||||
type BracketMatchInput,
|
||||
} from "../scoring-calculator";
|
||||
import { BRACKET_TEMPLATES } from "../../lib/bracket-templates";
|
||||
import { calculateSplitQualifyingPoints, DEFAULT_QP_VALUES } from "../qualifying-points";
|
||||
|
||||
// ── Test fixtures ─────────────────────────────────────────────────────────────
|
||||
|
||||
const SIMPLE_8 = BRACKET_TEMPLATES["simple_8"];
|
||||
const getConfig = (round: string) => getRoundConfig(round, "simple_8");
|
||||
const states = (matches: BracketMatchInput[]) =>
|
||||
deriveBracketQualifyingStates(matches, SIMPLE_8.rounds, getConfig);
|
||||
|
||||
const QP_MAP = new Map<number, number>(
|
||||
DEFAULT_QP_VALUES.map((v) => [v.placement, v.points])
|
||||
);
|
||||
const qpFor = (placement: number, tieCount: number) =>
|
||||
calculateSplitQualifyingPoints(placement, tieCount, QP_MAP);
|
||||
|
||||
/** Helper to build a played match. */
|
||||
const played = (
|
||||
round: string,
|
||||
winnerId: string,
|
||||
loserId: string
|
||||
): BracketMatchInput => ({
|
||||
round,
|
||||
winnerId,
|
||||
loserId,
|
||||
participant1Id: winnerId,
|
||||
participant2Id: loserId,
|
||||
});
|
||||
|
||||
/** Helper to build an as-yet-unplayed match (participants seeded, no result). */
|
||||
const pending = (
|
||||
round: string,
|
||||
p1: string,
|
||||
p2: string
|
||||
): BracketMatchInput => ({
|
||||
round,
|
||||
winnerId: null,
|
||||
loserId: null,
|
||||
participant1Id: p1,
|
||||
participant2Id: p2,
|
||||
});
|
||||
|
||||
// Full simple_8 quarterfinal field: winners t1,t2,t3,t4 / losers t5,t6,t7,t8.
|
||||
const QF_ALL = [
|
||||
played("Quarterfinals", "t1", "t8"),
|
||||
played("Quarterfinals", "t2", "t7"),
|
||||
played("Quarterfinals", "t3", "t6"),
|
||||
played("Quarterfinals", "t4", "t5"),
|
||||
];
|
||||
// Semifinals: t1 & t3 advance.
|
||||
const SF_ALL = [
|
||||
played("Semifinals", "t1", "t2"),
|
||||
played("Semifinals", "t3", "t4"),
|
||||
];
|
||||
// Final: t1 champion.
|
||||
const FINAL = [played("Finals", "t1", "t3")];
|
||||
|
||||
// ── deriveBracketQualifyingStates ─────────────────────────────────────────────
|
||||
|
||||
describe("deriveBracketQualifyingStates (simple_8)", () => {
|
||||
it("returns an empty map for no matches", () => {
|
||||
expect(states([]).size).toBe(0);
|
||||
});
|
||||
|
||||
it("QF entered: losers lock T5–8, winners floor at T3–4", () => {
|
||||
const s = states(QF_ALL);
|
||||
// Losers — final placement 5, tie span 4 (T5–8).
|
||||
for (const id of ["t5", "t6", "t7", "t8"]) {
|
||||
expect(s.get(id)).toEqual({ placement: 5, tieCount: 4 });
|
||||
}
|
||||
// Winners — guaranteed at least T3–4: floor placement 3, tie span 2.
|
||||
for (const id of ["t1", "t2", "t3", "t4"]) {
|
||||
expect(s.get(id)).toEqual({ placement: 3, tieCount: 2 });
|
||||
}
|
||||
});
|
||||
|
||||
it("QF+SF entered: SF losers lock T3–4, SF winners floor at 2nd", () => {
|
||||
const s = states([...QF_ALL, ...SF_ALL]);
|
||||
// SF losers — final placement 3, tie span 2.
|
||||
expect(s.get("t2")).toEqual({ placement: 3, tieCount: 2 });
|
||||
expect(s.get("t4")).toEqual({ placement: 3, tieCount: 2 });
|
||||
// SF winners (finalists-in-waiting) — floor placement 2, tie span 1.
|
||||
expect(s.get("t1")).toEqual({ placement: 2, tieCount: 1 });
|
||||
expect(s.get("t3")).toEqual({ placement: 2, tieCount: 1 });
|
||||
// QF losers unchanged.
|
||||
expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 });
|
||||
});
|
||||
|
||||
it("full bracket: champion 1st, finalist 2nd, SF losers T3–4, QF losers T5–8", () => {
|
||||
const s = states([...QF_ALL, ...SF_ALL, ...FINAL]);
|
||||
expect(s.get("t1")).toEqual({ placement: 1, tieCount: 1 }); // champion
|
||||
expect(s.get("t3")).toEqual({ placement: 2, tieCount: 1 }); // finalist
|
||||
expect(s.get("t2")).toEqual({ placement: 3, tieCount: 2 }); // SF loser
|
||||
expect(s.get("t4")).toEqual({ placement: 3, tieCount: 2 }); // SF loser
|
||||
expect(s.get("t5")).toEqual({ placement: 5, tieCount: 4 }); // QF loser
|
||||
});
|
||||
|
||||
it("partial QF (2 of 4 played): unplayed teams sit at the T5–8 entry floor", () => {
|
||||
const matches = [
|
||||
played("Quarterfinals", "t1", "t8"),
|
||||
played("Quarterfinals", "t4", "t5"),
|
||||
pending("Quarterfinals", "t3", "t6"),
|
||||
pending("Quarterfinals", "t2", "t7"),
|
||||
];
|
||||
const s = states(matches);
|
||||
// Played winners floor at T3–4.
|
||||
expect(s.get("t1")).toEqual({ placement: 3, tieCount: 2 });
|
||||
expect(s.get("t4")).toEqual({ placement: 3, tieCount: 2 });
|
||||
// Played losers lock T5–8.
|
||||
expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 });
|
||||
expect(s.get("t5")).toEqual({ placement: 5, tieCount: 4 });
|
||||
// Unplayed teams: entry floor T5–8 (no crash on incomplete round).
|
||||
for (const id of ["t2", "t3", "t6", "t7"]) {
|
||||
expect(s.get(id)).toEqual({ placement: 5, tieCount: 4 });
|
||||
}
|
||||
});
|
||||
|
||||
it("un-setting the Finals winner reverts both finalists to the 2nd-place floor", () => {
|
||||
const matches = [
|
||||
...QF_ALL,
|
||||
...SF_ALL,
|
||||
pending("Finals", "t1", "t3"), // winner cleared
|
||||
];
|
||||
const s = states(matches);
|
||||
expect(s.get("t1")).toEqual({ placement: 2, tieCount: 1 });
|
||||
expect(s.get("t3")).toEqual({ placement: 2, tieCount: 1 });
|
||||
});
|
||||
|
||||
it("a team eliminated in the QF never gains a later-round floor", () => {
|
||||
const s = states([...QF_ALL, ...SF_ALL, ...FINAL]);
|
||||
// t8 lost in the QF and stays T5–8 through the rest of the bracket.
|
||||
expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 });
|
||||
});
|
||||
|
||||
it("is idempotent — same matches yield the same states", () => {
|
||||
const a = states([...QF_ALL, ...SF_ALL]);
|
||||
const b = states([...QF_ALL, ...SF_ALL]);
|
||||
expect([...a.entries()].toSorted()).toEqual([...b.entries()].toSorted());
|
||||
});
|
||||
});
|
||||
|
||||
// ── tennis_128: scoring starts deep (Round of 16), not at round 1 ─────────────
|
||||
|
||||
describe("deriveBracketQualifyingStates (tennis_128 — deep scoring start)", () => {
|
||||
const TENNIS = BRACKET_TEMPLATES["tennis_128"];
|
||||
const tStates = (matches: BracketMatchInput[]) =>
|
||||
deriveBracketQualifyingStates(matches, TENNIS.rounds, (round) =>
|
||||
getRoundConfig(round, "tennis_128"),
|
||||
);
|
||||
|
||||
it("awards NO QP to a player who lost before the Round of 16", () => {
|
||||
const s = tStates([
|
||||
played("Round of 128", "winner", "earlyLoser"),
|
||||
]);
|
||||
expect(s.has("earlyLoser")).toBe(false); // 0 QP — didn't reach scoring
|
||||
});
|
||||
|
||||
it("awards NO QP just for winning a pre-scoring round (still short of R16)", () => {
|
||||
// Won R128, next match (R64) not played → not yet in the Round of 16.
|
||||
const s = tStates([played("Round of 128", "p", "x")]);
|
||||
expect(s.has("p")).toBe(false);
|
||||
});
|
||||
|
||||
it("gives NO entry floor to undrawn/unplayed players (the 9th-place bug)", () => {
|
||||
const s = tStates([
|
||||
pending("Round of 128", "a", "b"),
|
||||
pending("Round of 128", "c", "d"),
|
||||
]);
|
||||
expect(s.size).toBe(0); // nobody has earned anything yet
|
||||
});
|
||||
|
||||
it("floors a player who reached the Round of 16 (won R32) at T9–16", () => {
|
||||
// a wins R128 → R64 → R32, so a is now IN the Round of 16 (not yet played).
|
||||
const s = tStates([
|
||||
played("Round of 128", "a", "x1"),
|
||||
played("Round of 64", "a", "x2"),
|
||||
played("Round of 32", "a", "x3"),
|
||||
]);
|
||||
expect(s.get("a")).toEqual({ placement: 9, tieCount: 8 });
|
||||
// The players a beat in non-scoring rounds earn nothing.
|
||||
expect(s.has("x1")).toBe(false);
|
||||
});
|
||||
|
||||
it("locks an actual Round of 16 loser at T9–16 and floors the winner at T5–8", () => {
|
||||
const s = tStates([
|
||||
played("Round of 16", "r16winner", "r16loser"),
|
||||
]);
|
||||
expect(s.get("r16loser")).toEqual({ placement: 9, tieCount: 8 });
|
||||
expect(s.get("r16winner")).toEqual({ placement: 5, tieCount: 4 }); // QF floor
|
||||
});
|
||||
});
|
||||
|
||||
// ── QP values (the worked example) ────────────────────────────────────────────
|
||||
|
||||
describe("guaranteed-minimum QP per outcome (default config)", () => {
|
||||
it("matches the worked example: QF win → 9, SF win → 14, Final win → 20", () => {
|
||||
expect(qpFor(3, 2)).toBeCloseTo(9); // QF winner, guaranteed T3–4
|
||||
expect(qpFor(2, 1)).toBeCloseTo(14); // SF winner, guaranteed 2nd
|
||||
expect(qpFor(1, 1)).toBeCloseTo(20); // champion
|
||||
});
|
||||
|
||||
it("losers: QF loss → 4, SF loss → 9, Final loss → 14", () => {
|
||||
expect(qpFor(5, 4)).toBeCloseTo(4); // (5+5+3+3)/4
|
||||
expect(qpFor(3, 2)).toBeCloseTo(9); // (10+8)/2
|
||||
expect(qpFor(2, 1)).toBeCloseTo(14);
|
||||
});
|
||||
|
||||
it("end-to-end: a QF win immediately yields a 9 QP floor", () => {
|
||||
const s = states(QF_ALL);
|
||||
expect(s.get("t1")).toEqual({ placement: 3, tieCount: 2 });
|
||||
expect(qpFor(3, 2)).toBeCloseTo(9);
|
||||
expect(s.get("t8")).toEqual({ placement: 5, tieCount: 4 });
|
||||
expect(qpFor(5, 4)).toBeCloseTo(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Regression guard: structural tieCount, not live count (the #1 fix) ─────────
|
||||
|
||||
describe("structural tie span vs. live-count regrouping", () => {
|
||||
it("4 QF winners share placement 3 but each keeps the 2-slot (9 QP) floor", () => {
|
||||
const s = states(QF_ALL);
|
||||
// All four QF winners sit at placement 3, but with the STRUCTURAL tie span of 2.
|
||||
for (const id of ["t1", "t2", "t3", "t4"]) {
|
||||
expect(s.get(id)).toEqual({ placement: 3, tieCount: 2 });
|
||||
}
|
||||
expect(qpFor(3, 2)).toBeCloseTo(9);
|
||||
// A naive regroup by LIVE count (4 rows at placement 3) would average over
|
||||
// slots 3–6 and wrongly yield 7 QP — the bug processQualifyingEvent must avoid
|
||||
// by delegating bracket events to the structural-tieCount path.
|
||||
expect(qpFor(3, 4)).toBeCloseTo(7);
|
||||
expect(qpFor(3, 4)).not.toBeCloseTo(9);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Regression guard: reused ROUND_CONFIG floors are intact ────────────────────
|
||||
|
||||
describe("ROUND_CONFIG floors (regression guard)", () => {
|
||||
it("simple_8 Quarterfinals still floors winners at 3rd", () => {
|
||||
expect(getGuaranteedMinimumPosition("Quarterfinals", "simple_8", true)).toBe(3);
|
||||
expect(getRoundConfig("Quarterfinals", "simple_8")?.loserPosition).toBe(5);
|
||||
expect(getRoundConfig("Semifinals", "simple_8")?.winnerFloor).toBe(2);
|
||||
expect(getRoundConfig("Finals", "simple_8")?.winnerFloor).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,47 +2,9 @@ import { describe, it, expect } from "vitest";
|
|||
import {
|
||||
calculateSplitQualifyingPoints,
|
||||
DEFAULT_QP_VALUES,
|
||||
diffChangedQualifyingPoints,
|
||||
hasProcessedQualifyingPlacement,
|
||||
} from "../qualifying-points";
|
||||
|
||||
describe("diffChangedQualifyingPoints", () => {
|
||||
it("flags a changed value, a first-time score (null → value), and ignores unchanged", () => {
|
||||
const before = [
|
||||
{ id: "A", qp: "10.00" },
|
||||
{ id: "B", qp: "5.00" },
|
||||
{ id: "C", qp: null },
|
||||
];
|
||||
const after = [
|
||||
{ id: "A", qp: "10.00" }, // unchanged
|
||||
{ id: "B", qp: "8.00" }, // changed
|
||||
{ id: "C", qp: "3.00" }, // first-time score
|
||||
];
|
||||
|
||||
expect([...diffChangedQualifyingPoints(before, after)].toSorted()).toEqual(["B", "C"]);
|
||||
});
|
||||
|
||||
it("normalizes decimal formatting so 10 and 10.00 are equal", () => {
|
||||
const changed = diffChangedQualifyingPoints([{ id: "A", qp: "10" }], [{ id: "A", qp: "10.00" }]);
|
||||
expect(changed.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not report participants absent from the after set", () => {
|
||||
const changed = diffChangedQualifyingPoints(
|
||||
[
|
||||
{ id: "A", qp: "10.00" },
|
||||
{ id: "D", qp: "2.00" },
|
||||
],
|
||||
[{ id: "A", qp: "10.00" }],
|
||||
);
|
||||
expect(changed.size).toBe(0);
|
||||
});
|
||||
|
||||
it("treats a value → zero drop as a change", () => {
|
||||
const changed = diffChangedQualifyingPoints([{ id: "A", qp: "10.00" }], [{ id: "A", qp: "0.00" }]);
|
||||
expect([...changed]).toEqual(["A"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Qualifying Points Configuration", () => {
|
||||
describe("DEFAULT_QP_VALUES", () => {
|
||||
it("should have 16 placement values", () => {
|
||||
|
|
@ -309,6 +271,52 @@ describe("Qualifying Points Configuration", () => {
|
|||
expect(totalQP2).toBe(20); // Should have 20 QP (1st place)
|
||||
});
|
||||
|
||||
it("should not increment majorsCompleted when reprocessing", () => {
|
||||
// First processing
|
||||
let majorsCompleted = 0;
|
||||
const wasAlreadyProcessed = false;
|
||||
|
||||
if (!wasAlreadyProcessed) {
|
||||
majorsCompleted += 1;
|
||||
}
|
||||
|
||||
expect(majorsCompleted).toBe(1);
|
||||
|
||||
// Reprocessing (wasAlreadyProcessed = true)
|
||||
const reprocessing = true;
|
||||
|
||||
if (!reprocessing) {
|
||||
majorsCompleted += 1;
|
||||
}
|
||||
|
||||
expect(majorsCompleted).toBe(1); // Should still be 1, not 2
|
||||
});
|
||||
});
|
||||
|
||||
describe("Processed event detection", () => {
|
||||
it("treats a placed zero-QP result as already processed", () => {
|
||||
expect(
|
||||
hasProcessedQualifyingPlacement([
|
||||
{ placement: 20, qualifyingPointsAwarded: "0.00" },
|
||||
])
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat filler zero-QP rows without placements as processed", () => {
|
||||
expect(
|
||||
hasProcessedQualifyingPlacement([
|
||||
{ placement: null, qualifyingPointsAwarded: "0" },
|
||||
])
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat unprocessed placed rows as processed", () => {
|
||||
expect(
|
||||
hasProcessedQualifyingPlacement([
|
||||
{ placement: 15, qualifyingPointsAwarded: null },
|
||||
])
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scoring Workflow", () => {
|
||||
|
|
|
|||
|
|
@ -1,143 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type * as QualifyingPointsModule from "~/models/qualifying-points";
|
||||
|
||||
// Spy on the QP Discord notification, and no-op the per-participant total recalc
|
||||
// (its own eventResults query would otherwise interfere with the before/after
|
||||
// snapshot counter below). Everything else in the scorer runs for real.
|
||||
vi.mock("~/services/qualifying-points-discord.server", () => ({
|
||||
notifyQualifyingPointsUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("~/models/qualifying-points", async (importActual) => {
|
||||
const actual = await importActual<typeof QualifyingPointsModule>();
|
||||
return {
|
||||
...actual,
|
||||
recalculateParticipantQP: vi.fn().mockResolvedValue({ totalQP: 0, eventsScored: 0 }),
|
||||
};
|
||||
});
|
||||
|
||||
import { processQualifyingEvent } from "../scoring-calculator";
|
||||
import { DEFAULT_QP_VALUES } from "../qualifying-points";
|
||||
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
|
||||
|
||||
const EVENT_ID = "event-1";
|
||||
const SPORTS_SEASON_ID = "sports-season-1";
|
||||
|
||||
type ResultRow = {
|
||||
id: string;
|
||||
seasonParticipantId: string;
|
||||
placement: number | null;
|
||||
qualifyingPointsAwarded: string | null;
|
||||
scoringEvent: { id: string; sportsSeasonId: string };
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock db for the non-bracket `processQualifyingEvent` path. `eventResults.findMany`
|
||||
* returns `before` on its first call (the initial getEventResults snapshot) and
|
||||
* `after` on its second (the post-reprocess re-query that drives change detection).
|
||||
* Awarding updates go to a no-op update chain — `after` stands in for the DB state
|
||||
* once the scorer has written its QP.
|
||||
*/
|
||||
function makeDb(before: ResultRow[], after: ResultRow[]) {
|
||||
const results = [before, after];
|
||||
let findManyCall = 0;
|
||||
return {
|
||||
query: {
|
||||
scoringEvents: {
|
||||
findFirst: async () => ({
|
||||
id: EVENT_ID,
|
||||
sportsSeasonId: SPORTS_SEASON_ID,
|
||||
isQualifyingEvent: true,
|
||||
bracketTemplateId: null,
|
||||
sportsSeason: { majorsCompleted: 1 },
|
||||
}),
|
||||
},
|
||||
eventResults: {
|
||||
findMany: async () => results[Math.min(findManyCall++, 1)],
|
||||
},
|
||||
qualifyingPointConfig: {
|
||||
findMany: async () =>
|
||||
DEFAULT_QP_VALUES.map((qp) => ({ placement: qp.placement, points: qp.points.toString() })),
|
||||
},
|
||||
},
|
||||
update: () => ({ set: () => ({ where: async () => [] }) }),
|
||||
} as never;
|
||||
}
|
||||
|
||||
const scoringEvent = { id: EVENT_ID, sportsSeasonId: SPORTS_SEASON_ID };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("processQualifyingEvent — QP change notification", () => {
|
||||
it("notifies only participants whose QP changed (first-time score: null → value)", async () => {
|
||||
const db = makeDb(
|
||||
[
|
||||
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: null, scoringEvent },
|
||||
{ id: "r2", seasonParticipantId: "p2", placement: 20, qualifyingPointsAwarded: "0.00", scoringEvent },
|
||||
],
|
||||
[
|
||||
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
|
||||
{ id: "r2", seasonParticipantId: "p2", placement: 20, qualifyingPointsAwarded: "0.00", scoringEvent },
|
||||
],
|
||||
);
|
||||
|
||||
await processQualifyingEvent(EVENT_ID, db);
|
||||
|
||||
expect(notifyQualifyingPointsUpdate).toHaveBeenCalledTimes(1);
|
||||
const [ssId, evId, , filter] = vi.mocked(notifyQualifyingPointsUpdate).mock.calls[0];
|
||||
expect(ssId).toBe(SPORTS_SEASON_ID);
|
||||
expect(evId).toBe(EVENT_ID);
|
||||
// p1 went null → 100 (changed); p2 stayed at 0 (unchanged).
|
||||
expect([...(filter as Set<string>)]).toEqual(["p1"]);
|
||||
});
|
||||
|
||||
it("does not notify when a re-sync produces identical QP", async () => {
|
||||
const rows: ResultRow[] = [
|
||||
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
|
||||
{ id: "r2", seasonParticipantId: "p2", placement: 20, qualifyingPointsAwarded: "0.00", scoringEvent },
|
||||
];
|
||||
const db = makeDb(rows, rows.map((r) => ({ ...r })));
|
||||
|
||||
await processQualifyingEvent(EVENT_ID, db);
|
||||
|
||||
expect(notifyQualifyingPointsUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("announces newly-eliminated players even when no QP changed (mirror non-scoring-round exit)", async () => {
|
||||
// A mirror window re-scored on fan-out: nobody's QP changed, but the primary
|
||||
// bracket reports p2 knocked out in a non-scoring round. The notification must
|
||||
// still fire, passing the eliminated id through as the 5th arg so the "Knocked
|
||||
// Out" section isn't dropped on the mirror.
|
||||
const rows: ResultRow[] = [
|
||||
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
|
||||
{ id: "r2", seasonParticipantId: "p2", placement: null, qualifyingPointsAwarded: "0.00", scoringEvent },
|
||||
];
|
||||
const db = makeDb(rows, rows.map((r) => ({ ...r })));
|
||||
|
||||
await processQualifyingEvent(EVENT_ID, db, {
|
||||
newlyEliminatedParticipantIds: new Set(["p2"]),
|
||||
});
|
||||
|
||||
expect(notifyQualifyingPointsUpdate).toHaveBeenCalledTimes(1);
|
||||
const [, , , changed, eliminated] = vi.mocked(notifyQualifyingPointsUpdate).mock.calls[0];
|
||||
// No QP change this sync.
|
||||
expect([...(changed as Set<string>)]).toEqual([]);
|
||||
// The knocked-out player is forwarded to the notifier.
|
||||
expect([...(eliminated as Set<string>)]).toEqual(["p2"]);
|
||||
});
|
||||
|
||||
it("does not notify on a re-sync with no QP change and no eliminations", async () => {
|
||||
const rows: ResultRow[] = [
|
||||
{ id: "r1", seasonParticipantId: "p1", placement: 1, qualifyingPointsAwarded: "100.00", scoringEvent },
|
||||
];
|
||||
const db = makeDb(rows, rows.map((r) => ({ ...r })));
|
||||
|
||||
await processQualifyingEvent(EVENT_ID, db, {
|
||||
newlyEliminatedParticipantIds: new Set(),
|
||||
});
|
||||
|
||||
expect(notifyQualifyingPointsUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -200,21 +200,7 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
|||
});
|
||||
|
||||
describe("processQualifyingEvent", () => {
|
||||
function makeProcessQPMockDb(
|
||||
eventResults: Array<Record<string, unknown>>,
|
||||
options: {
|
||||
tournamentId?: string | null;
|
||||
canonicalPlacements?: number[];
|
||||
bracketTemplateId?: string | null;
|
||||
playoffMatchIds?: string[];
|
||||
} = {}
|
||||
) {
|
||||
const {
|
||||
tournamentId = null,
|
||||
canonicalPlacements = [],
|
||||
bracketTemplateId = null,
|
||||
playoffMatchIds = [],
|
||||
} = options;
|
||||
function makeProcessQPMockDb(eventResults: Array<Record<string, unknown>>) {
|
||||
const setCalls: unknown[] = [];
|
||||
const updateChain = {
|
||||
set: (values: unknown) => {
|
||||
|
|
@ -231,8 +217,6 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
|||
id: "event-1",
|
||||
sportsSeasonId: "sports-season-1",
|
||||
isQualifyingEvent: true,
|
||||
tournamentId,
|
||||
bracketTemplateId,
|
||||
sportsSeason: { majorsCompleted: 1 },
|
||||
}),
|
||||
},
|
||||
|
|
@ -254,23 +238,6 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
|||
}),
|
||||
},
|
||||
},
|
||||
// Two select shapes flow through the non-bracket path:
|
||||
// • playoff-match existence check (has an `id` projection, uses .limit(1))
|
||||
// • canonical tournament_results tie span (has a `placement` projection).
|
||||
// Discriminate by the projection keys and return a thenable that also
|
||||
// exposes .limit so both call shapes resolve.
|
||||
select: (fields: Record<string, unknown>) => {
|
||||
const isPlayoff = fields && "id" in fields;
|
||||
const rows = isPlayoff
|
||||
? playoffMatchIds.map((id) => ({ id }))
|
||||
: canonicalPlacements.map((placement) => ({ placement }));
|
||||
return {
|
||||
from: () => ({
|
||||
where: () =>
|
||||
Object.assign(Promise.resolve(rows), { limit: async () => rows }),
|
||||
}),
|
||||
};
|
||||
},
|
||||
update: () => updateChain,
|
||||
} as any;
|
||||
|
||||
|
|
@ -306,123 +273,6 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("splits a tied placement by the FULL canonical field, not the window's roster subset", async () => {
|
||||
// Regression for the reported 2-vs-1.5 QP bug. Tennis Round-of-16 losers all
|
||||
// land at placement 9 with a structural tie span of 8 (positions 9–16 →
|
||||
// (2+2+2+2+1+1+1+1)/8 = 1.5). A sibling/mirror window only holds the drafted
|
||||
// subset — here just 2 of the 8 tied players — but the split must still use 8,
|
||||
// not the 2 present locally (which would wrongly give (2+2)/2 = 2.00).
|
||||
const windowRows = [
|
||||
{
|
||||
id: "result-1",
|
||||
seasonParticipantId: "participant-1",
|
||||
placement: 9,
|
||||
qualifyingPointsAwarded: null,
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
{
|
||||
id: "result-2",
|
||||
seasonParticipantId: "participant-2",
|
||||
placement: 9,
|
||||
qualifyingPointsAwarded: null,
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
];
|
||||
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
|
||||
tournamentId: "tournament-1",
|
||||
canonicalPlacements: Array.from({ length: 8 }, () => 9), // full field: 8 at 9th
|
||||
});
|
||||
|
||||
await processQualifyingEvent("event-1", db, { skipNotifications: true });
|
||||
|
||||
// Both present players earn the correct split of 1.50, not 2.00.
|
||||
expect(setCalls).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ qualifyingPointsAwarded: "1.50" }),
|
||||
])
|
||||
);
|
||||
expect(setCalls).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("scores a cloned window (bracketTemplateId but no playoff matches) via the canonical path", async () => {
|
||||
// cloneSportsSeason copies bracketTemplateId to league windows but not the
|
||||
// playoff matches. Such a window must NOT take the bracket branch (which would
|
||||
// derive zero states and write no QP) — it has to fall through to the
|
||||
// placement/canonical path and still split R16 losers to 1.5.
|
||||
const windowRows = [
|
||||
{
|
||||
id: "result-1",
|
||||
seasonParticipantId: "participant-1",
|
||||
placement: 9,
|
||||
qualifyingPointsAwarded: null,
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
{
|
||||
id: "result-2",
|
||||
seasonParticipantId: "participant-2",
|
||||
placement: 9,
|
||||
qualifyingPointsAwarded: null,
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
];
|
||||
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
|
||||
tournamentId: "tournament-1",
|
||||
bracketTemplateId: "tennis_128", // copied by clone…
|
||||
playoffMatchIds: [], // …but no matches exist on this window
|
||||
canonicalPlacements: Array.from({ length: 8 }, () => 9),
|
||||
});
|
||||
|
||||
await processQualifyingEvent("event-1", db, { skipNotifications: true });
|
||||
|
||||
expect(setCalls).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ qualifyingPointsAwarded: "1.50" }),
|
||||
])
|
||||
);
|
||||
expect(setCalls).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the live roster count for standalone events with no tournament", async () => {
|
||||
// No canonical field exists for a manual/standalone qualifying event, so the
|
||||
// tie span is the players actually present: 2 players tied at 9th →
|
||||
// (2+2)/2 = 2.00. This preserves existing behavior where there is no full field.
|
||||
const windowRows = [
|
||||
{
|
||||
id: "result-1",
|
||||
seasonParticipantId: "participant-1",
|
||||
placement: 9,
|
||||
qualifyingPointsAwarded: null,
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
{
|
||||
id: "result-2",
|
||||
seasonParticipantId: "participant-2",
|
||||
placement: 9,
|
||||
qualifyingPointsAwarded: null,
|
||||
scoringEvent: { id: "event-1", sportsSeasonId: "sports-season-1" },
|
||||
},
|
||||
];
|
||||
const { db, setCalls } = makeProcessQPMockDb(windowRows, {
|
||||
tournamentId: null,
|
||||
});
|
||||
|
||||
await processQualifyingEvent("event-1", db, { skipNotifications: true });
|
||||
|
||||
expect(setCalls).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ qualifyingPointsAwarded: "2.00" }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("does not increment majorsCompleted when reprocessing a placed zero-QP result", async () => {
|
||||
const { db, setCalls } = makeProcessQPMockDb([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -15,10 +15,7 @@ vi.mock("../qualifying-points", async (importOriginal) => {
|
|||
import { deleteScoringEvent } from "../scoring-event";
|
||||
|
||||
describe("deleteScoringEvent", () => {
|
||||
it("does not write majorsCompleted on delete (it is derived on read)", async () => {
|
||||
// majorsCompleted is no longer a stored counter — it is computed via
|
||||
// getMajorsCompleted from completed qualifying events. Deleting a qualifying
|
||||
// event must therefore never issue a sportsSeasons.majorsCompleted update.
|
||||
it("decrements majorsCompleted for a processed zero-QP qualifying event", async () => {
|
||||
const setCalls: unknown[] = [];
|
||||
const deleteChain = { where: vi.fn().mockResolvedValue(undefined) };
|
||||
const updateChain = {
|
||||
|
|
@ -46,6 +43,12 @@ describe("deleteScoringEvent", () => {
|
|||
},
|
||||
]),
|
||||
},
|
||||
sportsSeasons: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
id: "sports-season-1",
|
||||
majorsCompleted: 1,
|
||||
}),
|
||||
},
|
||||
},
|
||||
transaction: vi.fn(async (callback) => callback(db)),
|
||||
delete: vi.fn(() => deleteChain),
|
||||
|
|
@ -54,125 +57,10 @@ describe("deleteScoringEvent", () => {
|
|||
|
||||
await deleteScoringEvent("event-1", db);
|
||||
|
||||
expect(setCalls).not.toEqual(
|
||||
expect(setCalls).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ majorsCompleted: expect.anything() }),
|
||||
expect.objectContaining({ majorsCompleted: 0 }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
describe("shared tournament handling", () => {
|
||||
// Builds a mock db for a non-qualifying schedule_event linked to a tournament,
|
||||
// so the delete skips the QP/league branches and exercises only the
|
||||
// shared-tournament bookkeeping.
|
||||
function makeSharedDb({
|
||||
deletedEvent,
|
||||
remaining,
|
||||
siblingTournamentId = "tournament-1",
|
||||
}: {
|
||||
deletedEvent: { tournamentId: string | null; isPrimary: boolean };
|
||||
remaining: Array<{ id: string; isPrimary: boolean }>;
|
||||
siblingTournamentId?: string;
|
||||
}) {
|
||||
const findFirst = vi
|
||||
.fn()
|
||||
// 1st call: the event being deleted
|
||||
.mockResolvedValueOnce({
|
||||
id: "event-1",
|
||||
sportsSeasonId: "sports-season-1",
|
||||
eventType: "schedule_event",
|
||||
isQualifyingEvent: false,
|
||||
...deletedEvent,
|
||||
})
|
||||
// subsequent calls: setPrimaryEvent looking up the promoted event
|
||||
.mockResolvedValue({ id: "promoted", tournamentId: siblingTournamentId });
|
||||
|
||||
const deleteWhere = vi.fn().mockResolvedValue(undefined);
|
||||
const updateWhere = vi.fn().mockResolvedValue(undefined);
|
||||
const db = {
|
||||
query: {
|
||||
scoringEvents: {
|
||||
findFirst,
|
||||
findMany: vi.fn().mockResolvedValue(remaining),
|
||||
},
|
||||
},
|
||||
transaction: vi.fn(async (callback) => callback(db)),
|
||||
delete: vi.fn(() => ({ where: deleteWhere })),
|
||||
update: vi.fn(() => ({ set: vi.fn(() => ({ where: updateWhere })) })),
|
||||
} as any;
|
||||
return { db, deleteWhere };
|
||||
}
|
||||
|
||||
it("keeps the tournament and promotes nothing when a non-primary window is deleted", async () => {
|
||||
const { db } = makeSharedDb({
|
||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: false },
|
||||
remaining: [{ id: "event-2", isPrimary: true }],
|
||||
});
|
||||
|
||||
const result = await deleteScoringEvent("event-1", db);
|
||||
|
||||
expect(result.remainingWindows).toBe(1);
|
||||
expect(result.promotedPrimaryId).toBeNull();
|
||||
expect(result.deletedTournament).toBe(false);
|
||||
});
|
||||
|
||||
it("does not promote a primary when a golf-style tournament (no primary) loses a window", async () => {
|
||||
// Golf-style shared majors intentionally have no primary window — every
|
||||
// linked event is isPrimary=false. Deleting one must not flip a sibling
|
||||
// into a primary, which would change its scoring/guard behavior.
|
||||
const { db } = makeSharedDb({
|
||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: false },
|
||||
remaining: [
|
||||
{ id: "event-2", isPrimary: false },
|
||||
{ id: "event-3", isPrimary: false },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await deleteScoringEvent("event-1", db);
|
||||
|
||||
expect(result.promotedPrimaryId).toBeNull();
|
||||
expect(result.remainingWindows).toBe(2);
|
||||
});
|
||||
|
||||
it("promotes the earliest remaining window when the primary window is deleted", async () => {
|
||||
const { db } = makeSharedDb({
|
||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
|
||||
// findMany is ordered asc(createdAt); first is the earliest.
|
||||
remaining: [
|
||||
{ id: "event-2", isPrimary: false },
|
||||
{ id: "event-3", isPrimary: false },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await deleteScoringEvent("event-1", db);
|
||||
|
||||
expect(result.promotedPrimaryId).toBe("event-2");
|
||||
expect(result.remainingWindows).toBe(2);
|
||||
});
|
||||
|
||||
it("leaves the orphaned tournament intact when the last window is deleted without the flag", async () => {
|
||||
const { db } = makeSharedDb({
|
||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
|
||||
remaining: [],
|
||||
});
|
||||
|
||||
const result = await deleteScoringEvent("event-1", db);
|
||||
|
||||
expect(result.remainingWindows).toBe(0);
|
||||
expect(result.deletedTournament).toBe(false);
|
||||
});
|
||||
|
||||
it("deletes the orphaned tournament when the last window is deleted with the flag", async () => {
|
||||
const { db } = makeSharedDb({
|
||||
deletedEvent: { tournamentId: "tournament-1", isPrimary: true },
|
||||
remaining: [],
|
||||
});
|
||||
|
||||
const result = await deleteScoringEvent("event-1", db, {
|
||||
deleteOrphanTournament: true,
|
||||
});
|
||||
|
||||
expect(result.deletedTournament).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,23 +1,11 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const mockFindMany = vi.fn();
|
||||
const mockFindFirst = vi.fn();
|
||||
// Records each db.update(...).set(patch).where(pred) call for assertions.
|
||||
const updateCalls: Array<{ patch: unknown; where: unknown }> = [];
|
||||
const mockUpdate = vi.fn(() => ({
|
||||
set: (patch: unknown) => ({
|
||||
where: (where: unknown) => {
|
||||
updateCalls.push({ patch, where });
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockDb = {
|
||||
query: {
|
||||
scoringEvents: { findMany: mockFindMany, findFirst: mockFindFirst },
|
||||
scoringEvents: { findMany: mockFindMany },
|
||||
},
|
||||
update: mockUpdate,
|
||||
};
|
||||
|
||||
vi.mock("~/database/context", () => ({
|
||||
|
|
@ -26,10 +14,8 @@ vi.mock("~/database/context", () => ({
|
|||
|
||||
vi.mock("~/database/schema", () => ({
|
||||
scoringEvents: {
|
||||
id: "se.id",
|
||||
sportsSeasonId: "se.sports_season_id",
|
||||
tournamentId: "se.tournament_id",
|
||||
isPrimary: "se.is_primary",
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -37,14 +23,11 @@ vi.mock("drizzle-orm", () => ({
|
|||
eq: (col: unknown, val: unknown) => ({ type: "eq", col, val }),
|
||||
and: (...args: unknown[]) => ({ type: "and", args }),
|
||||
isNotNull: (col: unknown) => ({ type: "isNotNull", col }),
|
||||
asc: (col: unknown) => ({ type: "asc", col }),
|
||||
}));
|
||||
|
||||
import {
|
||||
getTournamentsBySportsSeason,
|
||||
getSportsSeasonsByTournament,
|
||||
ensurePrimaryEvent,
|
||||
setPrimaryEvent,
|
||||
} from "../scoring-event";
|
||||
|
||||
const SEASON_ID = "ss-1";
|
||||
|
|
@ -56,7 +39,6 @@ const mockTournamentB = { id: TOURNAMENT_ID_B, name: "US Open", year: 2026 };
|
|||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
updateCalls.length = 0;
|
||||
});
|
||||
|
||||
describe("getTournamentsBySportsSeason", () => {
|
||||
|
|
@ -134,37 +116,3 @@ describe("getSportsSeasonsByTournament", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensurePrimaryEvent", () => {
|
||||
it("sets the event primary when the tournament has no primary yet", async () => {
|
||||
mockFindFirst.mockResolvedValue(undefined); // no existing primary
|
||||
const result = await ensurePrimaryEvent(TOURNAMENT_ID_A, "ev-1");
|
||||
expect(result).toBe("ev-1");
|
||||
expect(updateCalls).toHaveLength(1);
|
||||
expect(updateCalls[0].patch).toMatchObject({ isPrimary: true });
|
||||
});
|
||||
|
||||
it("is idempotent: keeps the existing primary and writes nothing", async () => {
|
||||
mockFindFirst.mockResolvedValue({ id: "ev-existing" });
|
||||
const result = await ensurePrimaryEvent(TOURNAMENT_ID_A, "ev-2");
|
||||
expect(result).toBe("ev-existing");
|
||||
expect(updateCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setPrimaryEvent", () => {
|
||||
it("clears siblings then sets the chosen event primary", async () => {
|
||||
mockFindFirst.mockResolvedValue({ id: "ev-3", tournamentId: TOURNAMENT_ID_A });
|
||||
await setPrimaryEvent("ev-3");
|
||||
// Two updates: clear-all (isPrimary:false) then set-one (isPrimary:true).
|
||||
expect(updateCalls).toHaveLength(2);
|
||||
expect(updateCalls[0].patch).toMatchObject({ isPrimary: false });
|
||||
expect(updateCalls[1].patch).toMatchObject({ isPrimary: true });
|
||||
});
|
||||
|
||||
it("throws when the event is not linked to a tournament", async () => {
|
||||
mockFindFirst.mockResolvedValue({ id: "ev-4", tournamentId: null });
|
||||
await expect(setPrimaryEvent("ev-4")).rejects.toThrow(/not linked to a tournament/);
|
||||
expect(updateCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,192 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
|
||||
import { countSeasonRaces, hasRaceRun } from "../season-races";
|
||||
|
||||
vi.mock("~/database/context", () => ({
|
||||
database: vi.fn(),
|
||||
}));
|
||||
|
||||
const NOW = new Date("2026-08-17T12:00:00.000Z");
|
||||
const TODAY = "2026-08-17";
|
||||
|
||||
interface EventRow {
|
||||
eventType: string;
|
||||
isComplete: boolean;
|
||||
eventDate: string | null;
|
||||
eventStartsAt: Date | null;
|
||||
}
|
||||
|
||||
function makeEvent(overrides: Partial<EventRow> = {}): EventRow {
|
||||
return {
|
||||
eventType: "schedule_event",
|
||||
isComplete: false,
|
||||
eventDate: null,
|
||||
eventStartsAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function mockEvents(events: EventRow[]) {
|
||||
const { database } = await import("~/database/context");
|
||||
(database as unknown as MockInstance).mockReturnValue({
|
||||
query: {
|
||||
scoringEvents: {
|
||||
findMany: vi.fn().mockResolvedValue(events),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await mockEvents([]);
|
||||
});
|
||||
|
||||
describe("hasRaceRun", () => {
|
||||
it("trusts isComplete when an admin has set it", () => {
|
||||
expect(
|
||||
hasRaceRun(
|
||||
{ isComplete: true, eventDate: "2026-12-31", eventStartsAt: null },
|
||||
NOW,
|
||||
TODAY
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers eventStartsAt over eventDate", () => {
|
||||
// Started yesterday and long finished, even though eventDate is unset.
|
||||
expect(
|
||||
hasRaceRun(
|
||||
{
|
||||
isComplete: false,
|
||||
eventDate: null,
|
||||
eventStartsAt: new Date("2026-08-16T18:00:00.000Z"),
|
||||
},
|
||||
NOW,
|
||||
TODAY
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
hasRaceRun(
|
||||
{
|
||||
isComplete: false,
|
||||
eventDate: TODAY,
|
||||
eventStartsAt: new Date("2026-08-17T18:00:00.000Z"),
|
||||
},
|
||||
NOW,
|
||||
TODAY
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not call a race run the moment it goes green", () => {
|
||||
// Declaring the finale finished at the green flag would publish the
|
||||
// pre-race leader as champion at 100%, from standings without that race.
|
||||
const greenFlag = new Date(NOW.getTime() - 30 * 60 * 1000);
|
||||
expect(
|
||||
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("counts a race run once it has had time to finish", () => {
|
||||
const greenFlag = new Date(NOW.getTime() - 7 * 60 * 60 * 1000);
|
||||
expect(
|
||||
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("still honours isComplete for a race that just started", () => {
|
||||
const greenFlag = new Date(NOW.getTime() - 30 * 60 * 1000);
|
||||
expect(
|
||||
hasRaceRun({ isComplete: true, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a past date as run even when nobody marked it complete", () => {
|
||||
expect(
|
||||
hasRaceRun(
|
||||
{ isComplete: false, eventDate: "2026-08-16", eventStartsAt: null },
|
||||
NOW,
|
||||
TODAY
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a race happening today as still upcoming", () => {
|
||||
expect(
|
||||
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: null }, NOW, TODAY)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("treats an undated row as upcoming", () => {
|
||||
expect(
|
||||
hasRaceRun({ isComplete: false, eventDate: null, eventStartsAt: null }, NOW, TODAY)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countSeasonRaces", () => {
|
||||
it("counts schedule_event rows as races", async () => {
|
||||
// This is the whole bug: a season_standings calendar is stored as
|
||||
// schedule_event rows, and the simulator used to skip them.
|
||||
await mockEvents([
|
||||
makeEvent({ eventDate: "2026-03-01" }),
|
||||
makeEvent({ eventDate: "2026-04-01" }),
|
||||
makeEvent({ eventDate: "2026-09-01" }),
|
||||
]);
|
||||
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
||||
completed: 2,
|
||||
remaining: 1,
|
||||
total: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes the final_standings scoring row", async () => {
|
||||
await mockEvents([
|
||||
makeEvent({ eventDate: "2026-03-01" }),
|
||||
makeEvent({ eventType: "final_standings", eventDate: "2026-11-01" }),
|
||||
]);
|
||||
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
||||
completed: 1,
|
||||
remaining: 0,
|
||||
total: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("counts other event types too, whichever type the admin used", async () => {
|
||||
await mockEvents([
|
||||
makeEvent({ eventType: "major_tournament", eventDate: "2026-03-01" }),
|
||||
makeEvent({ eventType: "playoff_game", eventDate: "2026-09-01" }),
|
||||
]);
|
||||
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
||||
completed: 1,
|
||||
remaining: 1,
|
||||
total: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns zeroes when the season has no events", async () => {
|
||||
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
||||
completed: 0,
|
||||
remaining: 0,
|
||||
total: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("counts a realistic late-season IndyCar calendar", async () => {
|
||||
const calendar = [
|
||||
...Array.from({ length: 15 }, (_, i) =>
|
||||
makeEvent({ eventDate: `2026-0${((i % 6) + 3)}-0${(i % 9) + 1}` })
|
||||
),
|
||||
// The next race goes green in a few hours — still remaining.
|
||||
makeEvent({ eventStartsAt: new Date("2026-08-17T18:00:00.000Z") }),
|
||||
makeEvent({ eventStartsAt: new Date("2026-08-30T18:00:00.000Z") }),
|
||||
makeEvent({ eventType: "final_standings" }),
|
||||
];
|
||||
await mockEvents(calendar);
|
||||
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
||||
completed: 15,
|
||||
remaining: 2,
|
||||
total: 17,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -71,90 +71,4 @@ describe("simulator input model", () => {
|
|||
expect(byParticipant.get("generated-rating")?.rating).toBeNull();
|
||||
expect(byParticipant.get("legacy-direct-rating")?.rating).toBe(27.5);
|
||||
});
|
||||
|
||||
it("keeps direct sourceElo but suppresses generated sourceElo", async () => {
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue([
|
||||
{ id: "direct-elo" },
|
||||
{ id: "generated-elo" },
|
||||
]);
|
||||
mockDb.query.seasonParticipantSimulatorInputs.findMany.mockResolvedValue([
|
||||
{
|
||||
participantId: "direct-elo",
|
||||
sourceOdds: null,
|
||||
sourceElo: 1600,
|
||||
worldRanking: null,
|
||||
rating: null,
|
||||
projectedWins: null,
|
||||
projectedTablePoints: null,
|
||||
seed: null,
|
||||
region: null,
|
||||
metadata: { sourceEloMethod: "direct" },
|
||||
},
|
||||
{
|
||||
participantId: "generated-elo",
|
||||
sourceOdds: 750,
|
||||
sourceElo: 1480,
|
||||
worldRanking: null,
|
||||
rating: null,
|
||||
projectedWins: null,
|
||||
projectedTablePoints: null,
|
||||
seed: null,
|
||||
region: null,
|
||||
metadata: { sourceEloMethod: "sourceOdds" },
|
||||
},
|
||||
]);
|
||||
mockDb.query.seasonParticipantExpectedValues.findMany.mockResolvedValue([]);
|
||||
|
||||
const inputs = await getParticipantSimulatorInputs("season-1");
|
||||
const byParticipant = new Map(inputs.map((input) => [input.participantId, input]));
|
||||
|
||||
expect(byParticipant.get("direct-elo")?.sourceElo).toBe(1600);
|
||||
expect(byParticipant.get("generated-elo")?.sourceElo).toBeNull();
|
||||
});
|
||||
|
||||
it("hides an Elo flagged as projection-derived so the projection is re-derived", async () => {
|
||||
// This is what stops a stale Elo from winning the baseEloPriority race. A row
|
||||
// carrying projectedWins and a projectedWins method flag must surface with a
|
||||
// null sourceElo, so resolveSourceElos falls through to the projection rather
|
||||
// than reusing an Elo that was itself derived from an older projection.
|
||||
mockDb.query.seasonParticipants.findMany.mockResolvedValue([
|
||||
{ id: "projected" },
|
||||
{ id: "hand-entered" },
|
||||
]);
|
||||
mockDb.query.seasonParticipantSimulatorInputs.findMany.mockResolvedValue([
|
||||
{
|
||||
participantId: "projected",
|
||||
sourceOdds: null,
|
||||
sourceElo: 1561,
|
||||
worldRanking: null,
|
||||
rating: null,
|
||||
projectedWins: "95.00",
|
||||
projectedTablePoints: null,
|
||||
seed: null,
|
||||
region: null,
|
||||
metadata: { sourceEloMethod: "projectedWins" },
|
||||
},
|
||||
{
|
||||
participantId: "hand-entered",
|
||||
sourceOdds: null,
|
||||
sourceElo: 1561,
|
||||
worldRanking: null,
|
||||
rating: null,
|
||||
projectedWins: "95.00",
|
||||
projectedTablePoints: null,
|
||||
seed: null,
|
||||
region: null,
|
||||
metadata: {},
|
||||
},
|
||||
]);
|
||||
mockDb.query.seasonParticipantExpectedValues.findMany.mockResolvedValue([]);
|
||||
|
||||
const inputs = await getParticipantSimulatorInputs("season-1");
|
||||
const byParticipant = new Map(inputs.map((input) => [input.participantId, input]));
|
||||
|
||||
expect(byParticipant.get("projected")?.sourceElo).toBeNull();
|
||||
expect(byParticipant.get("projected")?.projectedWins).toBe(95);
|
||||
// No flag means the admin entered that Elo themselves — it is trusted as direct.
|
||||
expect(byParticipant.get("hand-entered")?.sourceElo).toBe(1561);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,11 +27,7 @@ vi.mock("drizzle-orm", () => ({
|
|||
asc: (col: unknown) => ({ type: "asc", col }),
|
||||
}));
|
||||
|
||||
import {
|
||||
findAllSportsSeasons,
|
||||
findDraftableSportsSeasons,
|
||||
findDraftScheduleForHorizon,
|
||||
} from "../sports-season";
|
||||
import { findAllSportsSeasons, findDraftableSportsSeasons } from "../sports-season";
|
||||
import { database } from "~/database/context";
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
|
@ -129,58 +125,3 @@ describe("findDraftableSportsSeasons", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findDraftScheduleForHorizon", () => {
|
||||
const horizonWindows = [
|
||||
{
|
||||
id: "w1",
|
||||
name: "2026 NBA Playoffs",
|
||||
year: 2026,
|
||||
status: "upcoming",
|
||||
draftOn: today,
|
||||
draftOff: future,
|
||||
sport: { id: "nba", name: "NBA", slug: "nba", iconUrl: null },
|
||||
},
|
||||
{
|
||||
id: "w2",
|
||||
name: "2026 F1 Season",
|
||||
year: 2026,
|
||||
status: "active",
|
||||
draftOn: today,
|
||||
draftOff: future,
|
||||
sport: { id: "f1", name: "F1", slug: "f1", iconUrl: null },
|
||||
},
|
||||
];
|
||||
|
||||
it("maps rows to windows carrying their sport info", async () => {
|
||||
vi.mocked(database).mockReturnValue(makeMockDb(horizonWindows) as never);
|
||||
const result = await findDraftScheduleForHorizon(6);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: "w1",
|
||||
draftOn: today,
|
||||
draftOff: future,
|
||||
sport: { id: "nba", name: "NBA" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an empty array when no windows overlap the horizon", async () => {
|
||||
vi.mocked(database).mockReturnValue(makeMockDb([]) as never);
|
||||
const result = await findDraftScheduleForHorizon(12);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("queries with a where clause, ordering, and the sport relation", async () => {
|
||||
const mockDb = makeMockDb([]);
|
||||
vi.mocked(database).mockReturnValue(mockDb as never);
|
||||
|
||||
await findDraftScheduleForHorizon(6);
|
||||
expect(mockDb.query.sportsSeasons.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.anything(),
|
||||
orderBy: expect.anything(),
|
||||
with: expect.anything(),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -80,9 +80,6 @@ function makeDb(
|
|||
},
|
||||
scoringEvents: {
|
||||
findFirst: vi.fn().mockResolvedValue({ bracketTemplateId: null }),
|
||||
// getBracketTemplateIdsForSportsSeasons filters to events that carry a
|
||||
// template, so "no bracket template" is an empty result, not a null row.
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
seasonParticipantResults: {
|
||||
findMany: vi.fn().mockResolvedValue(seasonResults),
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
findTournamentBySportNameYear,
|
||||
upsertTournament,
|
||||
updateTournamentStatus,
|
||||
deleteTournament,
|
||||
} from "../tournament";
|
||||
import { database } from "~/database/context";
|
||||
|
||||
|
|
@ -173,26 +172,3 @@ describe("updateTournamentStatus", () => {
|
|||
expect(result.status).toBe("in_progress");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteTournament", () => {
|
||||
it("deletes the tournament row by id", async () => {
|
||||
const where = vi.fn().mockResolvedValue(undefined);
|
||||
const db = { delete: vi.fn().mockReturnValue({ where }) };
|
||||
vi.mocked(database).mockReturnValue(db as never);
|
||||
|
||||
await deleteTournament(TOURNAMENT_ID);
|
||||
|
||||
expect(db.delete).toHaveBeenCalledTimes(1);
|
||||
expect(where).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses a provided db when passed (transaction)", async () => {
|
||||
const where = vi.fn().mockResolvedValue(undefined);
|
||||
const providedDb = { delete: vi.fn().mockReturnValue({ where }) };
|
||||
|
||||
await deleteTournament(TOURNAMENT_ID, providedDb as never);
|
||||
|
||||
expect(providedDb.delete).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(database)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,66 +0,0 @@
|
|||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import { and, desc, inArray, isNotNull } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Resolve which bracket template a sports season's placements should be scored against.
|
||||
*
|
||||
* A sports season can own several scoring events — a bracket plus schedule events, or a
|
||||
* re-created bracket alongside a stale one — and only some of them carry a
|
||||
* bracketTemplateId. Picking an arbitrary row is not harmless: calculateBracketPoints
|
||||
* falls back to the standard single 5th–8th tier when the template id is null, which
|
||||
* silently collapses the two-tier templates (llws_20, afl_10) so a team locked into
|
||||
* 5th–6th and one locked into 7th–8th both score the flat 5–8 average. The 3rd/4th
|
||||
* distinction that llws_20 and fifa_48 have goes the same way.
|
||||
*
|
||||
* So: only events that actually carry a template are considered, most recent first —
|
||||
* matching the "a re-created event wins over a stale one" rule the LLWS simulator uses
|
||||
* when it picks its bracket event.
|
||||
*
|
||||
* Every requested season gets an entry, null when it has no bracket event, so callers
|
||||
* can cache the negative result too.
|
||||
*/
|
||||
export async function getBracketTemplateIdsForSportsSeasons(
|
||||
sportsSeasonIds: string[],
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<Map<string, string | null>> {
|
||||
const resolved = new Map<string, string | null>(
|
||||
sportsSeasonIds.map((id) => [id, null])
|
||||
);
|
||||
if (sportsSeasonIds.length === 0) return resolved;
|
||||
|
||||
const db = providedDb || database();
|
||||
|
||||
const events = await db.query.scoringEvents.findMany({
|
||||
where: and(
|
||||
inArray(schema.scoringEvents.sportsSeasonId, sportsSeasonIds),
|
||||
isNotNull(schema.scoringEvents.bracketTemplateId)
|
||||
),
|
||||
columns: { sportsSeasonId: true, bracketTemplateId: true },
|
||||
// createdAt can tie when a bracket is generated in the same transaction as a
|
||||
// sibling event, so id breaks the tie and keeps the choice deterministic.
|
||||
orderBy: [desc(schema.scoringEvents.createdAt), desc(schema.scoringEvents.id)],
|
||||
});
|
||||
|
||||
for (const event of events) {
|
||||
// Ordered newest-first, so the first row seen for a season is the one to keep.
|
||||
// The isNotNull filter means bracketTemplateId is set, but a mocked or partial row
|
||||
// could still carry null — skip those rather than caching a null as a real answer.
|
||||
if (resolved.get(event.sportsSeasonId) === null && event.bracketTemplateId) {
|
||||
resolved.set(event.sportsSeasonId, event.bracketTemplateId);
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-season form of getBracketTemplateIdsForSportsSeasons.
|
||||
*/
|
||||
export async function getBracketTemplateIdForSportsSeason(
|
||||
sportsSeasonId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<string | null> {
|
||||
const resolved = await getBracketTemplateIdsForSportsSeasons([sportsSeasonId], providedDb);
|
||||
return resolved.get(sportsSeasonId) ?? null;
|
||||
}
|
||||
|
|
@ -15,10 +15,7 @@
|
|||
import { database } from "~/database/context";
|
||||
import { cs2MajorStageResults, seasonParticipants, eventResults } from "~/database/schema";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { getQPConfig, calculateSplitQualifyingPoints, writeEventResultsQP, recalculateParticipantQP } from "~/models/qualifying-points";
|
||||
import { deleteEventResults } from "~/models/event-result";
|
||||
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { getQPConfig, recalculateParticipantQP, calculateSplitQualifyingPoints } from "~/models/qualifying-points";
|
||||
|
||||
export interface Cs2StageResult {
|
||||
id: string;
|
||||
|
|
@ -129,57 +126,14 @@ export async function upsertCs2StageAssignments(
|
|||
* Called when the admin resets the event setup.
|
||||
*/
|
||||
export async function clearCs2StageAssignments(
|
||||
scoringEventId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
scoringEventId: string
|
||||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
const db = database();
|
||||
await db
|
||||
.delete(cs2MajorStageResults)
|
||||
.where(eq(cs2MajorStageResults.scoringEventId, scoringEventId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fully reset a CS2 Major event so it can be set up again from scratch.
|
||||
*
|
||||
* Clears more than stage assignments alone: it also removes every recorded
|
||||
* result for the event and recomputes the cached QP totals of the affected
|
||||
* participants. This matters because a stale stage assignment can leave a
|
||||
* phantom participant (e.g. an academy team entered in place of the main
|
||||
* roster) with provisional QP in event_results and a cached total that would
|
||||
* otherwise survive a stage-only reset and keep polluting the standings and
|
||||
* the EV simulation field.
|
||||
*
|
||||
* Deliberately does NOT touch the Champions-Stage bracket (playoffMatches) —
|
||||
* re-entry realigns the stage data to the existing bracket.
|
||||
*/
|
||||
export async function resetCs2Event(
|
||||
scoringEventId: string,
|
||||
sportsSeasonId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
|
||||
// Capture participants with results first so we can recompute their cached
|
||||
// QP totals after the rows are deleted (mirrors deleteScoringEvent).
|
||||
const existing = await db
|
||||
.select({ participantId: eventResults.seasonParticipantId })
|
||||
.from(eventResults)
|
||||
.where(eq(eventResults.scoringEventId, scoringEventId));
|
||||
const affectedParticipantIds = [...new Set(existing.map((r) => r.participantId))];
|
||||
|
||||
// Delete + recompute atomically so a mid-operation failure can't leave the
|
||||
// event torn (e.g. assignments gone but stale QP totals surviving in the
|
||||
// standings) — the exact phantom-QP state this reset exists to clear.
|
||||
await db.transaction(async (tx) => {
|
||||
await clearCs2StageAssignments(scoringEventId, tx);
|
||||
await deleteEventResults(scoringEventId, tx);
|
||||
|
||||
for (const participantId of affectedParticipantIds) {
|
||||
await recalculateParticipantQP(participantId, sportsSeasonId, tx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark teams as eliminated from a specific stage.
|
||||
* Each elimination entry specifies which stage the team was eliminated at —
|
||||
|
|
@ -440,30 +394,34 @@ export async function assignCs2EliminationQP(
|
|||
}
|
||||
}
|
||||
|
||||
// Snapshot existing QP before writing so the notification only covers new/changed participants,
|
||||
// preventing earlier stage exits from being re-announced on each subsequent stage call.
|
||||
const existingRows = await db.query.eventResults.findMany({
|
||||
where: eq(eventResults.scoringEventId, scoringEventId),
|
||||
});
|
||||
const existingQPBySPId = new Map<string, number>(
|
||||
existingRows
|
||||
.filter((r) => r.qualifyingPointsAwarded !== null)
|
||||
.map((r) => [r.seasonParticipantId, parseFloat(r.qualifyingPointsAwarded as string)])
|
||||
const now = new Date();
|
||||
|
||||
await Promise.all(
|
||||
[...resultsByParticipant.entries()].map(([seasonParticipantId, { qp, placement }]) =>
|
||||
db
|
||||
.insert(eventResults)
|
||||
.values({
|
||||
scoringEventId,
|
||||
seasonParticipantId,
|
||||
qualifyingPointsAwarded: qp.toString(),
|
||||
placement,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [eventResults.scoringEventId, eventResults.seasonParticipantId],
|
||||
set: {
|
||||
qualifyingPointsAwarded: qp.toString(),
|
||||
placement,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
await writeEventResultsQP(scoringEventId, sportsSeasonId, resultsByParticipant, db);
|
||||
|
||||
const changedIds = new Set<string>(
|
||||
[...resultsByParticipant.entries()]
|
||||
.filter(([id, { qp }]) => existingQPBySPId.get(id) !== qp)
|
||||
.map(([id]) => id)
|
||||
await Promise.all(
|
||||
[...resultsByParticipant.keys()].map((seasonParticipantId) =>
|
||||
recalculateParticipantQP(seasonParticipantId, sportsSeasonId)
|
||||
)
|
||||
);
|
||||
|
||||
if (changedIds.size > 0) {
|
||||
try {
|
||||
await notifyQualifyingPointsUpdate(sportsSeasonId, scoringEventId, db, changedIds);
|
||||
} catch (error) {
|
||||
logger.error(`[CS2MajorStage] QP Discord notification failed for event ${scoringEventId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import {
|
|||
calculateBracketPoints,
|
||||
calculateSharedPlacementPoints,
|
||||
} from "./scoring-rules";
|
||||
import { getBracketTemplateIdsForSportsSeasons } from "./bracket-template";
|
||||
|
||||
export async function createDraftPick(data: {
|
||||
seasonId: string;
|
||||
|
|
@ -176,10 +175,18 @@ export async function getDraftedParticipantsWithPoints(
|
|||
}
|
||||
|
||||
// Batch-fetch bracket template IDs (one per sports season)
|
||||
const bracketTemplateMap =
|
||||
bracketSeasonIds.size > 0
|
||||
? await getBracketTemplateIdsForSportsSeasons([...bracketSeasonIds], db)
|
||||
: new Map<string, string | null>();
|
||||
const bracketTemplateMap = new Map<string, string | null>();
|
||||
if (bracketSeasonIds.size > 0) {
|
||||
const events = await db.query.scoringEvents.findMany({
|
||||
where: inArray(schema.scoringEvents.sportsSeasonId, [...bracketSeasonIds]),
|
||||
columns: { sportsSeasonId: true, bracketTemplateId: true },
|
||||
});
|
||||
for (const ev of events) {
|
||||
if (!bracketTemplateMap.has(ev.sportsSeasonId)) {
|
||||
bracketTemplateMap.set(ev.sportsSeasonId, ev.bracketTemplateId ?? null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch-fetch QP totals for qualifying_points participants
|
||||
const qpMap = new Map<string, number>(); // participantId → totalQP
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@
|
|||
*/
|
||||
|
||||
import { database } from "~/database/context";
|
||||
import { seasonParticipantExpectedValues, seasonParticipants } from "~/database/schema";
|
||||
import { eq, and, count, sql } from "drizzle-orm";
|
||||
import { seasonParticipantExpectedValues, seasonParticipants, seasonParticipantSimulatorInputs } from "~/database/schema";
|
||||
import { eq, and, count, inArray, sql } from "drizzle-orm";
|
||||
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
|
||||
import { calculateEV, normalizeProbabilities, calculateReplacementLevel, calculateVORP } from "~/services/ev-calculator";
|
||||
import { batchUpsertParticipantSimulatorInputs } from "~/models/simulator";
|
||||
import { batchSaveParticipantSimulatorSourceOdds, batchUpsertParticipantSimulatorInputs } from "~/models/simulator";
|
||||
|
||||
export type ProbabilitySource = "manual" | "futures_odds" | "elo_simulation" | "performance_model";
|
||||
|
||||
|
|
@ -366,6 +366,95 @@ export async function batchUpsertParticipantEVs(
|
|||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save American odds for a batch of seasonParticipants without touching probabilities or EV.
|
||||
* Used by the futures-odds admin page to persist odds before running the full simulation.
|
||||
*/
|
||||
export async function batchSaveSourceOdds(
|
||||
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }>
|
||||
): Promise<void> {
|
||||
const db = database();
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
|
||||
for (const { participantId, sportsSeasonId, sourceOdds } of inputs) {
|
||||
const existing = await tx
|
||||
.select({ id: seasonParticipantExpectedValues.id })
|
||||
.from(seasonParticipantExpectedValues)
|
||||
.where(
|
||||
and(
|
||||
eq(seasonParticipantExpectedValues.participantId, participantId),
|
||||
eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await tx
|
||||
.update(seasonParticipantExpectedValues)
|
||||
.set({ sourceOdds, updatedAt: now })
|
||||
.where(eq(seasonParticipantExpectedValues.id, existing[0].id));
|
||||
} else {
|
||||
// Insert a stub record — probabilities/EV will be filled in by the simulator
|
||||
await tx.insert(seasonParticipantExpectedValues).values({
|
||||
participantId,
|
||||
sportsSeasonId,
|
||||
probFirst: "0",
|
||||
probSecond: "0",
|
||||
probThird: "0",
|
||||
probFourth: "0",
|
||||
probFifth: "0",
|
||||
probSixth: "0",
|
||||
probSeventh: "0",
|
||||
probEighth: "0",
|
||||
expectedValue: "0",
|
||||
source: "futures_odds",
|
||||
sourceOdds,
|
||||
calculatedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await batchSaveParticipantSimulatorSourceOdds(inputs);
|
||||
}
|
||||
|
||||
export async function clearSourceOddsForParticipants(
|
||||
sportsSeasonId: string,
|
||||
participantIds: string[]
|
||||
): Promise<void> {
|
||||
if (participantIds.length === 0) return;
|
||||
const db = database();
|
||||
const now = new Date();
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(seasonParticipantExpectedValues)
|
||||
.set({ sourceOdds: null, updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId),
|
||||
inArray(seasonParticipantExpectedValues.participantId, participantIds)
|
||||
)
|
||||
);
|
||||
await tx
|
||||
.update(seasonParticipantSimulatorInputs)
|
||||
.set({
|
||||
sourceOdds: null,
|
||||
rating: null,
|
||||
metadata: sql`coalesce(${seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(seasonParticipantSimulatorInputs.sportsSeasonId, sportsSeasonId),
|
||||
inArray(seasonParticipantSimulatorInputs.participantId, participantIds)
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist raw Elo ratings (and optional world rankings) for a batch of seasonParticipants.
|
||||
* Used by Elo-based simulators like snooker_bracket and darts_bracket.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { eq, and, inArray } from "drizzle-orm";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
|
||||
|
|
@ -95,42 +95,14 @@ export async function deleteParticipantResult(id: string): Promise<void> {
|
|||
}
|
||||
|
||||
export async function deleteParticipantResultsBySportsSeasonId(
|
||||
sportsSeasonId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
sportsSeasonId: string
|
||||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
const db = database();
|
||||
await db
|
||||
.delete(schema.seasonParticipantResults)
|
||||
.where(eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the results of specific participants within one sports season.
|
||||
*
|
||||
* The season-wide delete above is too blunt for a single bracket: results are keyed by
|
||||
* sports season, not by event, so wiping the season takes every other event's placements
|
||||
* with it. Scoping to the participants a bracket actually holds lets reprocess-bracket
|
||||
* rebuild that bracket from scratch while leaving the rest of the season alone.
|
||||
*
|
||||
* No-ops on an empty id list — `inArray` with no values is not a valid SQL predicate.
|
||||
*/
|
||||
export async function deleteParticipantResultsForParticipants(
|
||||
sportsSeasonId: string,
|
||||
participantIds: string[],
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<void> {
|
||||
if (participantIds.length === 0) return;
|
||||
const db = providedDb || database();
|
||||
await db
|
||||
.delete(schema.seasonParticipantResults)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId),
|
||||
inArray(schema.seasonParticipantResults.participantId, participantIds)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set result for a participant in a sports season
|
||||
* Points are calculated on-demand based on each fantasy league's scoring rules
|
||||
|
|
|
|||
|
|
@ -6,19 +6,8 @@ import {
|
|||
getBracketTemplate,
|
||||
buildNCAA68SlotMap,
|
||||
matchIndexForSeedSlot,
|
||||
llwsMatchNumber,
|
||||
llwsSideAndLocal,
|
||||
STANDARD_BRACKET_SEEDING,
|
||||
} from "~/lib/bracket-templates";
|
||||
import {
|
||||
LLWS_LOSER_ADVANCES_ROUNDS,
|
||||
resolveLLWSAdvancement,
|
||||
type LLWSResolvedDestination,
|
||||
} from "~/lib/llws-bracket";
|
||||
import {
|
||||
resolveAflWildcardPlacements,
|
||||
type AflWildcardResult,
|
||||
} from "~/lib/afl-wildcard-reseed";
|
||||
|
||||
export type PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
|
||||
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
|
||||
|
|
@ -153,108 +142,6 @@ export async function setMatchWinner(
|
|||
return match;
|
||||
}
|
||||
|
||||
/** A draw match with participants/winner already resolved to ids. */
|
||||
export interface ResolvedDrawMatch {
|
||||
externalMatchId: string;
|
||||
round: string;
|
||||
matchNumber: number;
|
||||
participant1Id: string | null;
|
||||
participant2Id: string | null;
|
||||
winnerId: string | null;
|
||||
loserId: string | null;
|
||||
isScoring: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate (or advance) a bracket from a fully-resolved external draw, keyed on
|
||||
* `externalMatchId` so repeated syncs update in place rather than duplicating.
|
||||
*
|
||||
* Unlike `generateBracketFromTemplate` (which seeds only round 1 and leaves later
|
||||
* rounds empty for `advanceWinnerTemplate`), this writes every round's actual
|
||||
* matchups and known winners directly from the source — appropriate for a feed
|
||||
* (e.g. Wikipedia) that reports the full draw including completed rounds. A row
|
||||
* is marked complete when both its winner and loser are known.
|
||||
*
|
||||
* @returns counts of rows written (inserted or updated), those carrying a result,
|
||||
* and the participant ids of losers whose match *transitioned to complete on this
|
||||
* run* (a row that was absent or not-yet-complete before and is complete now).
|
||||
* That set is the newly-decided eliminations — used to announce knockouts once,
|
||||
* idempotently across re-syncs, since playoff_matches persist between syncs.
|
||||
*/
|
||||
export async function populateBracketFromDraw(
|
||||
eventId: string,
|
||||
matches: ResolvedDrawMatch[]
|
||||
): Promise<{ written: number; completed: number; newlyDecidedLoserIds: string[] }> {
|
||||
const db = database();
|
||||
|
||||
const existing = await db.query.playoffMatches.findMany({
|
||||
where: eq(schema.playoffMatches.scoringEventId, eventId),
|
||||
});
|
||||
const byExternalId = new Map(
|
||||
existing
|
||||
.filter((m) => m.externalMatchId)
|
||||
.map((m) => [m.externalMatchId as string, m])
|
||||
);
|
||||
|
||||
const toInsert: NewPlayoffMatch[] = [];
|
||||
let written = 0;
|
||||
let completed = 0;
|
||||
const newlyDecidedLoserIds: string[] = [];
|
||||
|
||||
for (const m of matches) {
|
||||
const isComplete = m.winnerId !== null && m.loserId !== null;
|
||||
if (isComplete) completed++;
|
||||
|
||||
const existingRow = byExternalId.get(m.externalMatchId);
|
||||
// Loser is "newly decided" when this match reaches completion for the first
|
||||
// time: either a brand-new complete row, or an existing row that was not
|
||||
// complete before. Re-syncing an already-complete match yields nothing.
|
||||
if (isComplete && m.loserId && !existingRow?.isComplete) {
|
||||
newlyDecidedLoserIds.push(m.loserId);
|
||||
}
|
||||
|
||||
if (existingRow) {
|
||||
await db
|
||||
.update(schema.playoffMatches)
|
||||
.set({
|
||||
round: m.round,
|
||||
matchNumber: m.matchNumber,
|
||||
participant1Id: m.participant1Id,
|
||||
participant2Id: m.participant2Id,
|
||||
winnerId: m.winnerId,
|
||||
loserId: m.loserId,
|
||||
isComplete,
|
||||
isScoring: m.isScoring,
|
||||
templateRound: m.round,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.playoffMatches.id, existingRow.id));
|
||||
written++;
|
||||
} else {
|
||||
toInsert.push({
|
||||
scoringEventId: eventId,
|
||||
round: m.round,
|
||||
matchNumber: m.matchNumber,
|
||||
participant1Id: m.participant1Id,
|
||||
participant2Id: m.participant2Id,
|
||||
winnerId: m.winnerId,
|
||||
loserId: m.loserId,
|
||||
isComplete,
|
||||
isScoring: m.isScoring,
|
||||
templateRound: m.round,
|
||||
externalMatchId: m.externalMatchId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (toInsert.length > 0) {
|
||||
await createManyPlayoffMatches(toInsert);
|
||||
written += toInsert.length;
|
||||
}
|
||||
|
||||
return { written, completed, newlyDecidedLoserIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a standard single elimination bracket structure
|
||||
*/
|
||||
|
|
@ -478,11 +365,6 @@ export async function generateBracketFromTemplate(
|
|||
return await generateNBA20Bracket(eventId, template, participantIds);
|
||||
}
|
||||
|
||||
// LLWS 20 requires special handling for its two double-elimination brackets
|
||||
if (templateId === "llws_20") {
|
||||
return await generateLLWS20Bracket(eventId, template, participantIds);
|
||||
}
|
||||
|
||||
const matches: NewPlayoffMatch[] = [];
|
||||
|
||||
// Generate matches for each round in the template
|
||||
|
|
@ -746,11 +628,9 @@ async function generateNFL14Bracket(
|
|||
* Structure:
|
||||
* - Wildcard Round: 7v10, 8v9
|
||||
* - Qualifying Finals: 1v4, 2v3 (winners get bye to Preliminary Finals, losers to Semi-Finals)
|
||||
* - Elimination Finals: 5 and 6 host the two Wildcard winners, re-seeded by ladder
|
||||
* position — 5th draws the lower-ranked winner, 6th the higher-ranked one
|
||||
* - Semi-Finals: SF1 = QF1 loser v EF1 winner, SF2 = QF2 loser v EF2 winner
|
||||
* - Preliminary Finals: PF1 = QF1 winner v SF2 winner, PF2 = QF2 winner v SF1 winner
|
||||
* (the crossover keeps a QF loser away from the side that just beat it)
|
||||
* - Elimination Finals: 5v8, 6v7 (where 7 and 8 are wildcard winners)
|
||||
* - Semi-Finals: QF losers vs EF winners
|
||||
* - Preliminary Finals: QF winners vs SF winners
|
||||
* - Grand Final: PF winners
|
||||
*/
|
||||
async function generateAFL10Bracket(
|
||||
|
|
@ -802,16 +682,14 @@ async function generateAFL10Bracket(
|
|||
});
|
||||
}
|
||||
|
||||
// Elimination Finals: 5th and 6th host the two Wildcard winners. Which winner lands
|
||||
// where is decided by ladder position once both games are played (see
|
||||
// resolveAflWildcardPlacements), not by a fixed crossover from a Wildcard match.
|
||||
// Elimination Finals: 5th vs TBD (wildcard winner), 6th vs TBD (wildcard winner)
|
||||
const eliminationSeeding = [
|
||||
{ higher: 4, opponent: "lower-ranked WC winner" }, // #5 (index 4)
|
||||
{ higher: 5, opponent: "higher-ranked WC winner" }, // #6 (index 5)
|
||||
{ higher: 4, wildcard: 2 }, // #5 (index 4) vs Wildcard Match 2 winner
|
||||
{ higher: 5, wildcard: 1 }, // #6 (index 5) vs Wildcard Match 1 winner
|
||||
];
|
||||
|
||||
for (let i = 0; i < eliminationSeeding.length; i++) {
|
||||
const { higher, opponent } = eliminationSeeding[i];
|
||||
const { higher, wildcard } = eliminationSeeding[i];
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
round: "Elimination Finals",
|
||||
|
|
@ -821,11 +699,11 @@ async function generateAFL10Bracket(
|
|||
isComplete: false,
|
||||
isScoring: true, // Losers share 7th-8th
|
||||
templateRound: "Elimination Finals",
|
||||
seedInfo: participantIds ? `${higher + 1} vs ${opponent}` : null,
|
||||
seedInfo: participantIds ? `${higher + 1} vs WC${wildcard}` : null,
|
||||
});
|
||||
}
|
||||
|
||||
// Semi-Finals: SF n = QF n loser vs EF n winner (TBD vs TBD)
|
||||
// Semi-Finals: QF losers vs EF winners (TBD vs TBD)
|
||||
for (let i = 0; i < 2; i++) {
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
|
|
@ -871,257 +749,15 @@ async function generateAFL10Bracket(
|
|||
return await createManyPlayoffMatches(matches);
|
||||
}
|
||||
|
||||
/** What a re-seed changed, by Elimination Finals match number. */
|
||||
export interface AflEliminationReseed {
|
||||
vacated: number[];
|
||||
filled: Array<{ matchNumber: number; participantId: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the decided Wildcard winners in the Elimination Finals they belong in.
|
||||
*
|
||||
* The two winners are re-seeded by ladder position — 5th meets the lower-ranked one and
|
||||
* 6th the higher-ranked one — rather than crossing over from a fixed Wildcard match. That
|
||||
* destination depends on both games, so this reconciles both slots against the results
|
||||
* recorded so far every time it runs: it places a winner whose slot only became certain
|
||||
* once the other game was decided, and moves one that an earlier (or corrected) result,
|
||||
* or a bracket advanced before this rule existed, put in the other slot.
|
||||
*
|
||||
* `pending` supplies a result that may not be in the database yet — the row read back
|
||||
* while advancing a match can predate the winner being written to it.
|
||||
*
|
||||
* Idempotent: pairings that are already right do no writes.
|
||||
*/
|
||||
export async function reseedAflEliminationFinals(
|
||||
eventId: string,
|
||||
pending?: { matchId: string; winnerId: string }
|
||||
): Promise<AflEliminationReseed> {
|
||||
const [wcMatches, efMatches] = await Promise.all([
|
||||
findPlayoffMatchesByEventIdAndRound(eventId, "Wildcard Round"),
|
||||
findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals"),
|
||||
]);
|
||||
|
||||
// Nothing to reconcile against is a bad event id or a broken bracket, not a no-op.
|
||||
if (wcMatches.length === 0 || efMatches.length === 0) {
|
||||
throw new Error(
|
||||
`Event ${eventId} has no AFL Wildcard Round / Elimination Finals matches to re-seed`
|
||||
);
|
||||
}
|
||||
|
||||
const winnerByMatchNumber = new Map<number, string>();
|
||||
for (const wc of wcMatches) {
|
||||
const decidedWinner =
|
||||
pending && wc.id === pending.matchId ? pending.winnerId : wc.isComplete ? wc.winnerId : null;
|
||||
if (decidedWinner) winnerByMatchNumber.set(wc.matchNumber, decidedWinner);
|
||||
}
|
||||
|
||||
const results: AflWildcardResult[] = wcMatches.map((wc) => {
|
||||
const decidedWinner = winnerByMatchNumber.get(wc.matchNumber) ?? null;
|
||||
if (decidedWinner === null) return { matchNumber: wc.matchNumber, winnerSlot: null };
|
||||
if (decidedWinner === wc.participant1Id) return { matchNumber: wc.matchNumber, winnerSlot: 1 };
|
||||
if (decidedWinner === wc.participant2Id) return { matchNumber: wc.matchNumber, winnerSlot: 2 };
|
||||
throw new Error(
|
||||
`Wildcard Round match ${wc.matchNumber} winner is not one of its participants`
|
||||
);
|
||||
});
|
||||
|
||||
const wanted = new Map<number, string>();
|
||||
for (const placement of resolveAflWildcardPlacements(results)) {
|
||||
const placedWinner = winnerByMatchNumber.get(placement.wildcardMatchNumber);
|
||||
if (placedWinner) wanted.set(placement.eliminationMatchNumber, placedWinner);
|
||||
}
|
||||
|
||||
// Only these teams can legitimately be moved between the two Elimination Finals;
|
||||
// anyone else in a slot came from somewhere this function knows nothing about.
|
||||
const wildcardParticipants = new Set<string>();
|
||||
for (const wc of wcMatches) {
|
||||
if (wc.participant1Id) wildcardParticipants.add(wc.participant1Id);
|
||||
if (wc.participant2Id) wildcardParticipants.add(wc.participant2Id);
|
||||
}
|
||||
|
||||
const slotsToClear: Array<{ id: string; matchNumber: number }> = [];
|
||||
const slotsToFill: Array<{ id: string; matchNumber: number; participantId: string }> = [];
|
||||
|
||||
for (const efMatch of efMatches) {
|
||||
const occupant = efMatch.participant2Id;
|
||||
const belongsHere = wanted.get(efMatch.matchNumber) ?? null;
|
||||
if (occupant === belongsHere) continue;
|
||||
|
||||
if (occupant !== null && !wildcardParticipants.has(occupant)) {
|
||||
throw new Error(`EF ${efMatch.matchNumber} participant2 already filled`);
|
||||
}
|
||||
// Re-seeding a game that has already been played would rewrite who contested a
|
||||
// recorded result. Surface that (this message is not one callers swallow) rather
|
||||
// than quietly corrupting the bracket.
|
||||
if (occupant !== null && (efMatch.isComplete || efMatch.winnerId)) {
|
||||
throw new Error(
|
||||
`Elimination Finals match ${efMatch.matchNumber} already has a recorded result, ` +
|
||||
`so its Wildcard qualifier cannot be re-seeded — clear and regenerate the bracket`
|
||||
);
|
||||
}
|
||||
if (occupant !== null) slotsToClear.push({ id: efMatch.id, matchNumber: efMatch.matchNumber });
|
||||
if (belongsHere !== null) {
|
||||
slotsToFill.push({ id: efMatch.id, matchNumber: efMatch.matchNumber, participantId: belongsHere });
|
||||
}
|
||||
}
|
||||
|
||||
const reseed: AflEliminationReseed = {
|
||||
vacated: slotsToClear.map((slot) => slot.matchNumber),
|
||||
filled: slotsToFill.map(({ matchNumber, participantId }) => ({ matchNumber, participantId })),
|
||||
};
|
||||
|
||||
if (slotsToClear.length === 0 && slotsToFill.length === 0) return reseed;
|
||||
|
||||
// One transaction, vacating before filling: a half-applied re-seed would leave the
|
||||
// same team in both Elimination Finals.
|
||||
const db = database();
|
||||
await db.transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
for (const slot of slotsToClear) {
|
||||
await tx
|
||||
.update(schema.playoffMatches)
|
||||
.set({ participant2Id: null, updatedAt: now })
|
||||
.where(eq(schema.playoffMatches.id, slot.id));
|
||||
}
|
||||
for (const slot of slotsToFill) {
|
||||
await tx
|
||||
.update(schema.playoffMatches)
|
||||
.set({ participant2Id: slot.participantId, updatedAt: now })
|
||||
.where(eq(schema.playoffMatches.id, slot.id));
|
||||
}
|
||||
});
|
||||
|
||||
return reseed;
|
||||
}
|
||||
|
||||
/** What a Semi-Finals re-seed changed, by Semi-Finals match number. */
|
||||
export interface AflSemiFinalReseed {
|
||||
vacated: number[];
|
||||
filled: Array<{ matchNumber: number; participantId: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the decided Elimination Final winners in the Semi-Finals they belong in.
|
||||
*
|
||||
* Unlike the Wildcard Round, this pathway is fixed: Elimination Final n feeds Semi-Final
|
||||
* n, so SF1 is the QF1 loser against the EF1 winner and SF2 the QF2 loser against the EF2
|
||||
* winner. The crossover in this system comes a round later, at Semi-Final → Preliminary
|
||||
* Final, so that a Qualifying Final loser cannot meet the side that just beat it.
|
||||
*
|
||||
* Brackets advanced before this was fixed crossed the two winners — the EF1 winner went
|
||||
* to SF2 and the EF2 winner to SF1 — which is why this reconciles both slots against the
|
||||
* results recorded so far rather than writing the one it was called for: a winner sitting
|
||||
* in the wrong Semi-Final is vacated, and a corrected Elimination Final result pulls the
|
||||
* beaten team back out instead of leaving it alive.
|
||||
*
|
||||
* `pending` supplies a result that may not be in the database yet — the row read back
|
||||
* while advancing a match can predate the winner being written to it.
|
||||
*
|
||||
* Idempotent: pairings that are already right do no writes.
|
||||
*/
|
||||
export async function reseedAflSemiFinals(
|
||||
eventId: string,
|
||||
pending?: { matchId: string; winnerId: string }
|
||||
): Promise<AflSemiFinalReseed> {
|
||||
const [efMatches, sfMatches] = await Promise.all([
|
||||
findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals"),
|
||||
findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals"),
|
||||
]);
|
||||
|
||||
// Nothing to reconcile against is a bad event id or a broken bracket, not a no-op.
|
||||
if (efMatches.length === 0 || sfMatches.length === 0) {
|
||||
throw new Error(
|
||||
`Event ${eventId} has no AFL Elimination Finals / Semi-Finals matches to re-seed`
|
||||
);
|
||||
}
|
||||
|
||||
// Elimination Final n feeds Semi-Final n, so a decided winner's destination never
|
||||
// depends on the other game.
|
||||
const wanted = new Map<number, string>();
|
||||
for (const ef of efMatches) {
|
||||
const decidedWinner =
|
||||
pending && ef.id === pending.matchId ? pending.winnerId : ef.isComplete ? ef.winnerId : null;
|
||||
if (!decidedWinner) continue;
|
||||
if (decidedWinner !== ef.participant1Id && decidedWinner !== ef.participant2Id) {
|
||||
throw new Error(
|
||||
`Elimination Finals match ${ef.matchNumber} winner is not one of its participants`
|
||||
);
|
||||
}
|
||||
wanted.set(ef.matchNumber, decidedWinner);
|
||||
}
|
||||
|
||||
// Only these teams can legitimately be moved between the two Semi-Finals; anyone else
|
||||
// in a slot came from somewhere this function knows nothing about.
|
||||
const eliminationParticipants = new Set<string>();
|
||||
for (const ef of efMatches) {
|
||||
if (ef.participant1Id) eliminationParticipants.add(ef.participant1Id);
|
||||
if (ef.participant2Id) eliminationParticipants.add(ef.participant2Id);
|
||||
}
|
||||
|
||||
const slotsToClear: Array<{ id: string; matchNumber: number }> = [];
|
||||
const slotsToFill: Array<{ id: string; matchNumber: number; participantId: string }> = [];
|
||||
|
||||
for (const sfMatch of sfMatches) {
|
||||
const occupant = sfMatch.participant2Id;
|
||||
const belongsHere = wanted.get(sfMatch.matchNumber) ?? null;
|
||||
if (occupant === belongsHere) continue;
|
||||
|
||||
if (occupant !== null && !eliminationParticipants.has(occupant)) {
|
||||
throw new Error(`SF ${sfMatch.matchNumber} participant2 already filled`);
|
||||
}
|
||||
// Re-seeding a game that has already been played would rewrite who contested a
|
||||
// recorded result. Surface that (this message is not one callers swallow) rather
|
||||
// than quietly corrupting the bracket.
|
||||
if (occupant !== null && (sfMatch.isComplete || sfMatch.winnerId)) {
|
||||
throw new Error(
|
||||
`Semi-Finals match ${sfMatch.matchNumber} already has a recorded result, ` +
|
||||
`so its Elimination Finals qualifier cannot be re-seeded — clear and regenerate the bracket`
|
||||
);
|
||||
}
|
||||
if (occupant !== null) slotsToClear.push({ id: sfMatch.id, matchNumber: sfMatch.matchNumber });
|
||||
if (belongsHere !== null) {
|
||||
slotsToFill.push({ id: sfMatch.id, matchNumber: sfMatch.matchNumber, participantId: belongsHere });
|
||||
}
|
||||
}
|
||||
|
||||
const reseed: AflSemiFinalReseed = {
|
||||
vacated: slotsToClear.map((slot) => slot.matchNumber),
|
||||
filled: slotsToFill.map(({ matchNumber, participantId }) => ({ matchNumber, participantId })),
|
||||
};
|
||||
|
||||
if (slotsToClear.length === 0 && slotsToFill.length === 0) return reseed;
|
||||
|
||||
// One transaction, vacating before filling: a half-applied re-seed would leave the
|
||||
// same team in both Semi-Finals.
|
||||
const db = database();
|
||||
await db.transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
for (const slot of slotsToClear) {
|
||||
await tx
|
||||
.update(schema.playoffMatches)
|
||||
.set({ participant2Id: null, updatedAt: now })
|
||||
.where(eq(schema.playoffMatches.id, slot.id));
|
||||
}
|
||||
for (const slot of slotsToFill) {
|
||||
await tx
|
||||
.update(schema.playoffMatches)
|
||||
.set({ participant2Id: slot.participantId, updatedAt: now })
|
||||
.where(eq(schema.playoffMatches.id, slot.id));
|
||||
}
|
||||
});
|
||||
|
||||
return reseed;
|
||||
}
|
||||
|
||||
/**
|
||||
* AFL-specific advancement logic for the complex double-chance system
|
||||
* Phase 3.3: Handles both winners and losers advancing to different rounds
|
||||
*
|
||||
* Advancement rules:
|
||||
* - Wildcard Round: Winner → Elimination Finals (re-seeded by ladder position)
|
||||
* - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals (QF n → PF n, SF n)
|
||||
* - Elimination Finals: Winner → Semi-Finals (EF n → SF n, a fixed pathway)
|
||||
* - Semi-Finals: Winner → Preliminary Finals (SF n crosses over: SF1 → PF2, SF2 → PF1)
|
||||
* - Wildcard Round: Winner → Elimination Finals
|
||||
* - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals
|
||||
* - Elimination Finals: Winner → Semi-Finals
|
||||
* - Semi-Finals: Winner → Preliminary Finals
|
||||
* - Preliminary Finals: Winner → Grand Final
|
||||
*/
|
||||
async function advanceAFLWinner(
|
||||
|
|
@ -1131,10 +767,18 @@ async function advanceAFLWinner(
|
|||
): Promise<void> {
|
||||
const eventId = match.scoringEventId;
|
||||
|
||||
// Wildcard Round: winners are re-seeded into the Elimination Finals by ladder
|
||||
// position, so every result re-resolves both slots.
|
||||
// Wildcard Round: Winner advances to Elimination Finals
|
||||
if (match.round === "Wildcard Round") {
|
||||
await reseedAflEliminationFinals(eventId, { matchId: match.id, winnerId });
|
||||
// Wildcard Match 1 winner → EF Match 2, participant2Id
|
||||
// Wildcard Match 2 winner → EF Match 1, participant2Id
|
||||
const efMatchNumber = match.matchNumber === 1 ? 2 : 1;
|
||||
const efMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Elimination Finals");
|
||||
const efMatch = efMatches.find((m) => m.matchNumber === efMatchNumber);
|
||||
|
||||
if (!efMatch) throw new Error(`Elimination Finals match ${efMatchNumber} not found`);
|
||||
if (efMatch.participant2Id) throw new Error(`EF ${efMatchNumber} participant2 already filled`);
|
||||
|
||||
await updatePlayoffMatch(efMatch.id, { participant2Id: winnerId });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1162,11 +806,18 @@ async function advanceAFLWinner(
|
|||
return;
|
||||
}
|
||||
|
||||
// Elimination Finals: Winner → Semi-Finals. EF n feeds SF n — the crossover in this
|
||||
// system is a round later, at Semi-Finals → Preliminary Finals. Reconcile both slots so
|
||||
// a corrected result moves the qualifier instead of leaving the beaten team alive.
|
||||
// Elimination Finals: Winner → Semi-Finals
|
||||
if (match.round === "Elimination Finals") {
|
||||
await reseedAflSemiFinals(eventId, { matchId: match.id, winnerId });
|
||||
// EF Match 1 winner → SF2 participant2
|
||||
// EF Match 2 winner → SF1 participant2
|
||||
const sfMatchNumber = match.matchNumber === 1 ? 2 : 1;
|
||||
const sfMatches = await findPlayoffMatchesByEventIdAndRound(eventId, "Semi-Finals");
|
||||
const sfMatch = sfMatches.find((m) => m.matchNumber === sfMatchNumber);
|
||||
|
||||
if (!sfMatch) throw new Error(`Semi-Finals match ${sfMatchNumber} not found`);
|
||||
if (sfMatch.participant2Id) throw new Error(`SF ${sfMatchNumber} participant2 already filled`);
|
||||
|
||||
await updatePlayoffMatch(sfMatch.id, { participant2Id: winnerId });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1227,15 +878,6 @@ export async function advanceWinnerTemplate(
|
|||
return await advanceNBAPlayInWinner(match, winnerId, loserId);
|
||||
}
|
||||
|
||||
// Special handling for LLWS 20 double elimination: winners-bracket losers route
|
||||
// into the elimination bracket instead of being knocked out.
|
||||
if (template.id === "llws_20") {
|
||||
const loserId =
|
||||
match.participant1Id === winnerId ? match.participant2Id : match.participant1Id;
|
||||
if (!loserId) throw new Error("Cannot determine loser for LLWS advancement");
|
||||
return await advanceLLWSWinner(match, winnerId, loserId);
|
||||
}
|
||||
|
||||
// Special handling for AFL 10 double-chance system
|
||||
// Phase 3.3: AFL has complex winner/loser advancement rules
|
||||
if (template.id === "afl_10") {
|
||||
|
|
@ -1683,12 +1325,6 @@ export function doesLoserAdvance(
|
|||
if (templateId === "afl_10" && round === "Qualifying Finals") {
|
||||
return true;
|
||||
}
|
||||
// LLWS winners bracket: a loss drops the team into the elimination bracket, so it
|
||||
// must not be recorded as an elimination. (Winners Final and Bracket Championship
|
||||
// are scoring rounds and are handled via loserIsPartial instead.)
|
||||
if (templateId === "llws_20" && LLWS_LOSER_ADVANCES_ROUNDS.has(round)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -1800,140 +1436,3 @@ async function advanceNBAPlayInWinner(
|
|||
throw new Error(`Unknown Play-In Round 2 match number: ${match.matchNumber}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── LLWS 20 (double elimination) ──────────────────────────────────────────────
|
||||
|
||||
// The routing table itself is pure and lives in lib/ so the renderer can import it
|
||||
// without pulling the database context into the browser bundle. Re-exported here so
|
||||
// existing server-side callers and tests keep their import path.
|
||||
export { LLWS_LOSER_ADVANCES_ROUNDS, resolveLLWSAdvancement, type LLWSResolvedDestination };
|
||||
|
||||
/**
|
||||
* Generate the 20-team LLWS double-elimination bracket (38 matches).
|
||||
*
|
||||
* Only the Opening Round and the four bye slots receive participants up front;
|
||||
* everything else is filled by advanceLLWSWinner as games complete.
|
||||
*
|
||||
* Participant array layout (see LLWS_20 in lib/bracket-templates):
|
||||
* [0–7] U.S. Opening Round teams, two per game
|
||||
* [8, 9] U.S. bye teams → Winners Round 2 M1 / M2 participant1
|
||||
* [10–17] International Opening Round teams, two per game
|
||||
* [18,19] International bye teams → Winners Round 2 M3 / M4 participant1
|
||||
*/
|
||||
async function generateLLWS20Bracket(
|
||||
eventId: string,
|
||||
template: BracketTemplate,
|
||||
participantIds?: string[]
|
||||
): Promise<PlayoffMatch[]> {
|
||||
const matches: NewPlayoffMatch[] = [];
|
||||
const p = (idx: number): string | null =>
|
||||
participantIds ? (participantIds[idx] ?? null) : null;
|
||||
|
||||
const sides = [
|
||||
{ side: 0 as const, label: "U.S.", openingBase: 0, byeBase: 8 },
|
||||
{ side: 1 as const, label: "Intl", openingBase: 10, byeBase: 18 },
|
||||
];
|
||||
|
||||
// ── Opening Round: 4 games per side, both slots seeded ──────────────────────
|
||||
for (const { side, label, openingBase } of sides) {
|
||||
for (let local = 1; local <= 4; local++) {
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
round: "Opening Round",
|
||||
matchNumber: llwsMatchNumber("Opening Round", side, local),
|
||||
participant1Id: p(openingBase + (local - 1) * 2),
|
||||
participant2Id: p(openingBase + (local - 1) * 2 + 1),
|
||||
isComplete: false,
|
||||
isScoring: false,
|
||||
templateRound: "Opening Round",
|
||||
seedInfo: `${label} Opening ${local}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Winners Round 2: bye team at participant1, Opening winner at participant2 ─
|
||||
for (const { side, label, byeBase } of sides) {
|
||||
for (let local = 1; local <= 2; local++) {
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
round: "Winners Round 2",
|
||||
matchNumber: llwsMatchNumber("Winners Round 2", side, local),
|
||||
participant1Id: p(byeBase + (local - 1)),
|
||||
participant2Id: null, // Opening Round winner
|
||||
isComplete: false,
|
||||
isScoring: false,
|
||||
templateRound: "Winners Round 2",
|
||||
seedInfo: `${label} Bye ${local} vs Opening ${local} winner`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Every remaining round starts empty ──────────────────────────────────────
|
||||
const remaining = template.rounds.filter(
|
||||
(r) => r.name !== "Opening Round" && r.name !== "Winners Round 2"
|
||||
);
|
||||
for (const round of remaining) {
|
||||
for (let i = 1; i <= round.matchCount; i++) {
|
||||
// Championship/Consolation are single shared games; everything else is per-side.
|
||||
const perSide = round.matchCount > 1;
|
||||
const label = perSide
|
||||
? llwsSideAndLocal(round.name, i).side === 0
|
||||
? "U.S."
|
||||
: "Intl"
|
||||
: null;
|
||||
matches.push({
|
||||
scoringEventId: eventId,
|
||||
round: round.name,
|
||||
matchNumber: i,
|
||||
participant1Id: null,
|
||||
participant2Id: null,
|
||||
isComplete: false,
|
||||
isScoring: round.isScoring,
|
||||
templateRound: round.name,
|
||||
seedInfo: label ? `${label} ${round.name}` : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return await createManyPlayoffMatches(matches);
|
||||
}
|
||||
|
||||
/**
|
||||
* LLWS advancement: routes the winner forward and, in the winners bracket, routes the
|
||||
* loser into the elimination bracket rather than eliminating them.
|
||||
*
|
||||
* All routing decisions live in resolveLLWSAdvancement; this function only writes.
|
||||
*/
|
||||
async function advanceLLWSWinner(
|
||||
match: PlayoffMatch,
|
||||
winnerId: string,
|
||||
loserId: string
|
||||
): Promise<void> {
|
||||
const eventId = match.scoringEventId;
|
||||
const { winner, loser } = resolveLLWSAdvancement(match.round, match.matchNumber);
|
||||
|
||||
// Winner and loser can land in different rounds, so resolve each independently.
|
||||
const moves: Array<{ destination: LLWSResolvedDestination; participantId: string }> = [];
|
||||
if (winner) moves.push({ destination: winner, participantId: winnerId });
|
||||
if (loser) moves.push({ destination: loser, participantId: loserId });
|
||||
|
||||
for (const { destination, participantId } of moves) {
|
||||
const targetMatches = await findPlayoffMatchesByEventIdAndRound(
|
||||
eventId,
|
||||
destination.round
|
||||
);
|
||||
const target = targetMatches.find((m) => m.matchNumber === destination.matchNumber);
|
||||
if (!target) {
|
||||
throw new Error(
|
||||
`Next match not found: round=${destination.round}, matchNumber=${destination.matchNumber}`
|
||||
);
|
||||
}
|
||||
if (target[destination.slot]) {
|
||||
throw new Error(
|
||||
`Next match ${destination.slot} is already filled ` +
|
||||
`(round=${destination.round}, matchNumber=${destination.matchNumber})`
|
||||
);
|
||||
}
|
||||
await updatePlayoffMatch(target.id, { [destination.slot]: participantId });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,33 +33,6 @@ export function roundQualifyingPoints(points: number): number {
|
|||
return Math.round((points + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a participant's awarded QP before and after a re-score, return the ids
|
||||
* whose QP changed. A row's `qp` is the raw decimal string (or null when no QP
|
||||
* was awarded). A participant counts as changed when their new value differs from
|
||||
* the old, including a first-time score (null → value). Participants absent from
|
||||
* `after` are not reported (their removal is handled by the caller's recalc).
|
||||
*
|
||||
* Shared by the two QP re-score paths — processQualifyingEvent (sibling windows,
|
||||
* CS2, manual admin) and rescoreTennisBracketAndDetectChanges (tennis primary) —
|
||||
* so the "only announce real changes" semantics live in one place.
|
||||
*/
|
||||
export function diffChangedQualifyingPoints(
|
||||
before: Iterable<{ id: string; qp: string | null }>,
|
||||
after: Iterable<{ id: string; qp: string | null }>
|
||||
): Set<string> {
|
||||
const beforeQP = new Map<string, number>();
|
||||
for (const r of before) {
|
||||
if (r.qp !== null) beforeQP.set(r.id, parseFloat(r.qp));
|
||||
}
|
||||
const changed = new Set<string>();
|
||||
for (const r of after) {
|
||||
if (r.qp === null) continue;
|
||||
if (beforeQP.get(r.id) !== parseFloat(r.qp)) changed.add(r.id);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
export function calculateSplitQualifyingPoints(
|
||||
placement: number,
|
||||
tieCount: number,
|
||||
|
|
@ -75,6 +48,21 @@ export function calculateSplitQualifyingPoints(
|
|||
return roundQualifyingPoints(totalQP / tieCount);
|
||||
}
|
||||
|
||||
export function hasProcessedQualifyingPlacement(
|
||||
results: Array<{
|
||||
placement?: number | null;
|
||||
qualifyingPointsAwarded?: string | null;
|
||||
}>
|
||||
): boolean {
|
||||
return results.some(
|
||||
(result) =>
|
||||
result.placement !== null &&
|
||||
result.placement !== undefined &&
|
||||
result.qualifyingPointsAwarded !== null &&
|
||||
result.qualifyingPointsAwarded !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize default qualifying point configuration for a sports season
|
||||
*/
|
||||
|
|
@ -295,57 +283,6 @@ export async function recalculateParticipantQP(
|
|||
return { totalQP, eventsScored };
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert qualifying points + placement into event_results for a set of participants,
|
||||
* then recalculate each participant's QP total.
|
||||
*
|
||||
* Shared by the QP writers (assignCs2EliminationQP for Swiss-stage exits,
|
||||
* processQualifyingBracketEvent for Champions-Stage / knockout brackets) so the
|
||||
* upsert + recalc loop lives in one place. Upserts on
|
||||
* (scoringEventId, seasonParticipantId), so repeated calls overwrite cleanly.
|
||||
*
|
||||
* @param entries Map from seasonParticipantId → { qp, placement } to write.
|
||||
*/
|
||||
export async function writeEventResultsQP(
|
||||
scoringEventId: string,
|
||||
sportsSeasonId: string,
|
||||
entries: Map<string, { qp: number; placement: number }>,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<void> {
|
||||
if (entries.size === 0) return;
|
||||
const db = providedDb || database();
|
||||
const now = new Date();
|
||||
|
||||
await Promise.all(
|
||||
[...entries.entries()].map(([seasonParticipantId, { qp, placement }]) =>
|
||||
db
|
||||
.insert(schema.eventResults)
|
||||
.values({
|
||||
scoringEventId,
|
||||
seasonParticipantId,
|
||||
qualifyingPointsAwarded: qp.toFixed(2),
|
||||
placement,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [schema.eventResults.scoringEventId, schema.eventResults.seasonParticipantId],
|
||||
set: {
|
||||
qualifyingPointsAwarded: qp.toFixed(2),
|
||||
placement,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
[...entries.keys()].map((seasonParticipantId) =>
|
||||
recalculateParticipantQP(seasonParticipantId, sportsSeasonId, db)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate eventsScored for a participant by counting unique events with QP awarded
|
||||
* @deprecated Use recalculateParticipantQP instead for more accurate recalculation
|
||||
|
|
|
|||
|
|
@ -9,23 +9,20 @@ import {
|
|||
} from "./scoring-rules";
|
||||
import { getSeasonResults } from "./participant-season-result";
|
||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||
import { sendStandingsUpdateNotification, type ScoredMatch, type EliminatedTeam } from "~/services/discord";
|
||||
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
|
||||
import { BRACKET_TEMPLATES, type BracketRound } from "~/lib/bracket-templates";
|
||||
import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord";
|
||||
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
|
||||
import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-match";
|
||||
import { getUserDisplayName } from "~/models/user";
|
||||
import { findDiscordIdsByUserIds } from "~/models/account";
|
||||
import { createDailySnapshot } from "~/models/standings";
|
||||
import { getBracketTemplateIdForSportsSeason } from "~/models/bracket-template";
|
||||
import { recordMatchScoreEvents } from "~/models/team-score-events";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { getEventResults } from "./event-result";
|
||||
import {
|
||||
calculateSplitQualifyingPoints,
|
||||
diffChangedQualifyingPoints,
|
||||
getQPConfig,
|
||||
hasProcessedQualifyingPlacement,
|
||||
recalculateParticipantQP,
|
||||
writeEventResultsQP,
|
||||
getQPStandings,
|
||||
updateFinalRankings,
|
||||
} from "./qualifying-points";
|
||||
|
|
@ -114,169 +111,31 @@ const TEMPLATE_ROUND_CONFIG: Record<string, Record<string, RoundScoringConfig>>
|
|||
// 3rd place game finalizes both positions distinctly.
|
||||
"Third Place Game": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 },
|
||||
},
|
||||
llws_20: {
|
||||
// Winners Final loser drops to the Elimination Final, so 5th is provisional —
|
||||
// winning that game lifts them back to a 4th-place floor.
|
||||
"Winners Final": { loserPosition: 5, loserIsPartial: true, winnerFloor: 4 },
|
||||
// Elimination Round 4 losers are the 7th–8th tier (8 teams alive at this point).
|
||||
"Elimination Round 4": { loserPosition: 7, loserIsPartial: false, winnerFloor: 5 },
|
||||
// Elimination Final losers are the 5th–6th tier; the winner reaches the side
|
||||
// championship, where the worst case is 4th (lose it, then lose the consolation).
|
||||
"Elimination Final": { loserPosition: 5, loserIsPartial: false, winnerFloor: 4 },
|
||||
// Side championship loser still has the consolation game — provisional 4th.
|
||||
"Bracket Championship": { loserPosition: 4, loserIsPartial: true, winnerFloor: 2 },
|
||||
// Consolation finalizes 3rd and 4th distinctly.
|
||||
"Consolation Third Place": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 },
|
||||
"World Championship": { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
|
||||
},
|
||||
tennis_128: {
|
||||
// R16 losers share 9th–16th; winner advances to QF (floor 5th–8th).
|
||||
"Round of 16": { loserPosition: 9, loserIsPartial: false, winnerFloor: 5 },
|
||||
// QF losers share 5th–8th; winner advances to SF (floor 3rd–4th).
|
||||
Quarterfinals: { loserPosition: 5, loserIsPartial: false, winnerFloor: 3 },
|
||||
// SF losers share 3rd–4th; winner advances to Final (floor 2nd).
|
||||
Semifinals: { loserPosition: 3, loserIsPartial: false, winnerFloor: 2 },
|
||||
// Final finalizes both: winner 1st, loser 2nd.
|
||||
Final: { loserPosition: 2, loserIsPartial: false, winnerFloor: null },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the floor position that winners of a NON-scoring round should bank, or null
|
||||
* to bank nothing.
|
||||
* Returns true if a non-scoring round's winners are entering the first scoring round
|
||||
* (i.e., they've guaranteed a top-8 fantasy placement and should receive a T5–T8 floor).
|
||||
*
|
||||
* Default: winners entering the first scoring round have guaranteed a top-8 fantasy
|
||||
* placement and receive a T5–T8 floor (5); everyone else gets nothing yet. For
|
||||
* multi-round pre-bracket sequences like NCAA (Round of 64 → Round of 32 → Sweet
|
||||
* Sixteen → Elite Eight), only Sweet Sixteen winners are entering the scoring bracket.
|
||||
* For multi-round pre-bracket sequences like NCAA (Round of 64 → Round of 32 →
|
||||
* Sweet Sixteen → Elite Eight), only Sweet Sixteen winners are entering the scoring
|
||||
* bracket — Round of 64 and Round of 32 winners should not receive any floor yet.
|
||||
*
|
||||
* A round may override this with `nonScoringWinnerFloor` when the default is wrong —
|
||||
* in a double-elimination losers bracket a win can guarantee a worse finish than 5th
|
||||
* (llws_20 "Elimination Round 3" → 7), or nothing at all.
|
||||
*
|
||||
* Falls back to 5 when template/round info is unavailable, preserving legacy behavior.
|
||||
* Falls back to true when template/round info is unavailable to preserve legacy behavior.
|
||||
*/
|
||||
function nonScoringWinnerFloorFor(
|
||||
function doesNonScoringRoundFeedIntoScoringRound(
|
||||
round: string,
|
||||
bracketTemplateId: string | null | undefined
|
||||
): number | null {
|
||||
if (!bracketTemplateId) return 5; // Legacy: preserve old behavior
|
||||
): boolean {
|
||||
if (!bracketTemplateId) return true; // Legacy: preserve old behavior
|
||||
const template = BRACKET_TEMPLATES[bracketTemplateId];
|
||||
if (!template) return 5; // Unknown template: preserve old behavior
|
||||
if (!template) return true; // Unknown template: preserve old behavior
|
||||
const currentRound = template.rounds.find((r) => r.name === round);
|
||||
if (!currentRound) return 5; // Unknown round: preserve old behavior
|
||||
// Explicit per-round override wins, including an explicit null (bank nothing).
|
||||
if (currentRound.nonScoringWinnerFloor !== undefined) {
|
||||
return currentRound.nonScoringWinnerFloor;
|
||||
}
|
||||
if (!currentRound) return true; // Unknown round: preserve old behavior
|
||||
const nextRoundName = currentRound.feedsInto;
|
||||
if (!nextRoundName) return null; // No next round (shouldn't happen for non-scoring)
|
||||
if (!nextRoundName) return false; // No next round (shouldn't happen for non-scoring)
|
||||
const nextRound = template.rounds.find((r) => r.name === nextRoundName);
|
||||
return nextRound?.isScoring === true ? 5 : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the floor position a participant banks purely by being *seeded into* the
|
||||
* given round when the bracket is generated, or null when entry guarantees nothing.
|
||||
*
|
||||
* Two sources, in order:
|
||||
* 1. The template round's explicit `entryFloor` (e.g. afl_10 "Qualifying Finals" → 5:
|
||||
* seeds 1-4 have the double chance, so the 5th-6th tier is locked in on day one).
|
||||
* 2. Otherwise a scoring round's own loser position — being drawn into a round whose
|
||||
* losers score means the worst case is that round's loser tier.
|
||||
*
|
||||
* Non-scoring rounds with no explicit `entryFloor` return null: losing your first game
|
||||
* there is worth 0, so there is nothing to bank yet.
|
||||
*/
|
||||
export function getBracketEntryFloor(
|
||||
round: string,
|
||||
bracketTemplateId: string | null | undefined
|
||||
): number | null {
|
||||
const template = bracketTemplateId ? BRACKET_TEMPLATES[bracketTemplateId] : undefined;
|
||||
const templateRound = template?.rounds.find((r) => r.name === round);
|
||||
if (templateRound?.entryFloor !== undefined) return templateRound.entryFloor;
|
||||
if (!templateRound?.isScoring) return null;
|
||||
return getRoundConfig(round, bracketTemplateId)?.loserPosition ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the provisional entry floors for a freshly generated (or reprocessed) bracket.
|
||||
*
|
||||
* A seeded bracket can guarantee points before anyone plays: an AFL top-4 seed cannot
|
||||
* finish below the 5th-6th tier because a Qualifying Final loss still leaves them a
|
||||
* Semi-Final. Without this, those teams sit on 0 fantasy points until their first game
|
||||
* resolves, which understates every roster holding them.
|
||||
*
|
||||
* Only participants already assigned to a match slot are touched, and every write is
|
||||
* provisional (isPartialScore=true) so it is superseded the moment a real result lands.
|
||||
*
|
||||
* Floors never go backwards. A participant already sitting on an equal or better
|
||||
* placement is skipped, so regenerating a bracket mid-tournament (clear-bracket →
|
||||
* generate-bracket) cannot knock a finalist back down to their seeding floor. Combined
|
||||
* with upsertParticipantResult's never-un-finalize guard, re-running over the same
|
||||
* bracket is a no-op.
|
||||
*
|
||||
* Returns the number of participants whose floor this call actually raised.
|
||||
*/
|
||||
export async function applyBracketEntryFloors(
|
||||
eventId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<number> {
|
||||
const db = providedDb || database();
|
||||
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.id, eventId),
|
||||
});
|
||||
if (!event?.bracketTemplateId) return 0;
|
||||
|
||||
const matches = await db.query.playoffMatches.findMany({
|
||||
where: eq(schema.playoffMatches.scoringEventId, eventId),
|
||||
});
|
||||
|
||||
// Highest (best) floor wins when a participant somehow appears in more than one
|
||||
// round's slots — a lower position number is a better guarantee.
|
||||
const floorByParticipant = new Map<string, number>();
|
||||
for (const match of matches) {
|
||||
const floor = getBracketEntryFloor(match.round, event.bracketTemplateId);
|
||||
if (floor === null) continue;
|
||||
for (const participantId of [match.participant1Id, match.participant2Id]) {
|
||||
if (!participantId) continue;
|
||||
const existing = floorByParticipant.get(participantId);
|
||||
if (existing === undefined || floor < existing) {
|
||||
floorByParticipant.set(participantId, floor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Existing placements, so a floor is only ever written when it improves on what
|
||||
// the participant already has. Position 0 means eliminated / missed the bracket —
|
||||
// not a better placement — so it never blocks a floor.
|
||||
const existingRows = await db.query.seasonParticipantResults.findMany({
|
||||
where: eq(schema.seasonParticipantResults.sportsSeasonId, event.sportsSeasonId),
|
||||
columns: { participantId: true, finalPosition: true },
|
||||
});
|
||||
const existingPosition = new Map(
|
||||
existingRows
|
||||
.filter((r) => r.finalPosition !== null && r.finalPosition > 0)
|
||||
.map((r) => [r.participantId, r.finalPosition as number])
|
||||
);
|
||||
|
||||
let applied = 0;
|
||||
for (const [participantId, floor] of floorByParticipant) {
|
||||
const current = existingPosition.get(participantId);
|
||||
if (current !== undefined && current <= floor) continue; // already as good or better
|
||||
|
||||
const oldFloor = await upsertParticipantResult(
|
||||
participantId,
|
||||
event.sportsSeasonId,
|
||||
floor,
|
||||
db,
|
||||
true // provisional: replaced as soon as the participant wins or is eliminated
|
||||
);
|
||||
if (oldFloor !== null) applied++;
|
||||
}
|
||||
|
||||
return applied;
|
||||
return nextRound?.isScoring === true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -284,7 +143,7 @@ export async function applyBracketEntryFloors(
|
|||
* overrides before falling back to the standard ROUND_CONFIG.
|
||||
* Returns null for unrecognized rounds.
|
||||
*/
|
||||
export function getRoundConfig(
|
||||
function getRoundConfig(
|
||||
round: string,
|
||||
bracketTemplateId?: string | null
|
||||
): RoundScoringConfig | null {
|
||||
|
|
@ -410,18 +269,19 @@ export async function processPlayoffEvent(
|
|||
}
|
||||
|
||||
if (!isScoring) {
|
||||
// Non-scoring round: losers are permanently eliminated (0 pts) unless they
|
||||
// advance (double-elimination winners-bracket losers). Winners bank a
|
||||
// provisional floor only when this round guarantees them one — see
|
||||
// nonScoringWinnerFloorFor for how that is derived per template.
|
||||
const winnerFloor = nonScoringWinnerFloorFor(round, event.bracketTemplateId);
|
||||
// Non-scoring (pre-bracket) round: losers are permanently eliminated (0 pts).
|
||||
// Winners only bank a provisional T5–T8 floor if they're entering the first
|
||||
// scoring round (i.e., guaranteed top-8). For multi-round pre-bracket sequences
|
||||
// like NCAA (R64 → R32 → Sweet 16 → Elite Eight), only Sweet 16 winners should
|
||||
// receive floor points — R64 and R32 winners are not yet guaranteed top-8.
|
||||
const awardFloor = doesNonScoringRoundFeedIntoScoringRound(round, event.bracketTemplateId);
|
||||
for (const match of matches) {
|
||||
const loserAdvances = doesLoserAdvance(round, match.matchNumber, event.bracketTemplateId ?? "");
|
||||
if (match.loserId && !loserAdvances) {
|
||||
await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db);
|
||||
}
|
||||
if (match.winnerId && winnerFloor !== null) {
|
||||
await upsertParticipantResult(match.winnerId, event.sportsSeasonId, winnerFloor, db, true);
|
||||
if (match.winnerId && awardFloor) {
|
||||
await upsertParticipantResult(match.winnerId, event.sportsSeasonId, 5, db, true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -481,7 +341,7 @@ export async function processPlayoffEvent(
|
|||
// Progressive floor scoring: assign guaranteed minimum points to winners.
|
||||
// For Finals (winnerFloor=null) getGuaranteedMinimumPosition returns null — the
|
||||
// winner is already finalized as 1st above. For non-scoring rounds it also
|
||||
// returns null; those winners were given their floor inline above.
|
||||
// returns null (winners were given floor 5 inline above).
|
||||
const guaranteedMinimum = getGuaranteedMinimumPosition(
|
||||
round,
|
||||
event.bracketTemplateId,
|
||||
|
|
@ -547,19 +407,6 @@ export async function processMatchResult(
|
|||
/** When set, Discord notification only shows this match (not all completed matches for the event). */
|
||||
matchId?: string;
|
||||
skipSideEffects?: boolean;
|
||||
/**
|
||||
* Skip only the probability refresh, still recalculating standings and announcing.
|
||||
*
|
||||
* For a caller scoring several matches in a loop: the refresh is season-wide and
|
||||
* idempotent, so running it per match repeats the whole thing needlessly — and for a
|
||||
* bracket-aware sport that now means a full Monte Carlo run each time. Set this in the
|
||||
* loop and call updateProbabilitiesAfterResult once when it finishes. Per-match
|
||||
* announcements then project from the previous probabilities until that final call.
|
||||
*
|
||||
* Distinct from skipSideEffects, which also suppresses the standings recalculation and
|
||||
* the announcement.
|
||||
*/
|
||||
skipProbabilities?: boolean;
|
||||
/**
|
||||
* When true, the loser of this non-scoring round advances to another match
|
||||
* (e.g. NBA Play-In Round 1 7v8 loser → Play-In Round 2) and must NOT be
|
||||
|
|
@ -570,7 +417,7 @@ export async function processMatchResult(
|
|||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, skipProbabilities, loserAdvances } = params;
|
||||
const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, loserAdvances } = params;
|
||||
|
||||
if (!isScoring) {
|
||||
// Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts),
|
||||
|
|
@ -582,9 +429,8 @@ export async function processMatchResult(
|
|||
if (!loserAdvances) {
|
||||
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
|
||||
}
|
||||
const nonScoringFloor = nonScoringWinnerFloorFor(round, bracketTemplateId);
|
||||
if (nonScoringFloor !== null) {
|
||||
await upsertParticipantResult(winnerId, sportsSeasonId, nonScoringFloor, db, true);
|
||||
if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) {
|
||||
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
|
||||
}
|
||||
// Non-scoring round wins are not surfaced in the Recent Scores feed.
|
||||
} else {
|
||||
|
|
@ -650,15 +496,13 @@ export async function processMatchResult(
|
|||
: undefined;
|
||||
// Update probabilities first so the standings recalc reads fresh EVs and
|
||||
// projected points reflect the new result.
|
||||
if (!skipProbabilities) {
|
||||
try {
|
||||
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
try {
|
||||
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[ScoringCalculator] Failed to auto-update probabilities for sports season ${sportsSeasonId}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
|
||||
}
|
||||
|
|
@ -768,7 +612,6 @@ export function isLoserNotifiable(
|
|||
return scoreChanged || finalizedLoserIds.has(loserId);
|
||||
}
|
||||
|
||||
|
||||
export function getGuaranteedMinimumPosition(
|
||||
round: string,
|
||||
bracketTemplateId: string | null | undefined,
|
||||
|
|
@ -780,260 +623,13 @@ export function getGuaranteedMinimumPosition(
|
|||
return config.winnerFloor; // null for Finals; actual floor for all other rounds
|
||||
}
|
||||
|
||||
// ── Qualifying-points bracket scoring ────────────────────────────────────────
|
||||
|
||||
/** Minimal shape of a playoff match needed to derive qualifying-bracket states. */
|
||||
export interface BracketMatchInput {
|
||||
round: string;
|
||||
winnerId: string | null;
|
||||
loserId: string | null;
|
||||
participant1Id: string | null;
|
||||
participant2Id: string | null;
|
||||
}
|
||||
|
||||
/** The guaranteed-minimum placement (and its structural tie span) for one team. */
|
||||
export interface BracketQualifyingState {
|
||||
/** Placement tier the team has locked in (1, 2, 3, 5, …). */
|
||||
placement: number;
|
||||
/**
|
||||
* Number of bracket slots this placement tier spans (e.g. 4 for T5–8, 2 for
|
||||
* T3–4, 1 for 1st/2nd). Used to tie-split QP across the tier. This is the
|
||||
* *structural* span from the template — not the live count of teams currently
|
||||
* sitting at this placement — so the floor value is stable and idempotent and
|
||||
* agrees with processQualifyingEvent's finalization re-grouping.
|
||||
*/
|
||||
tieCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive each bracket team's guaranteed-minimum (placement, tieCount) from the
|
||||
* current set of playoff matches, reusing the standard ROUND_CONFIG floors.
|
||||
*
|
||||
* Rules per team (single-elimination ⇒ a team loses at most once):
|
||||
* - Lost in round R → final placement = loserPosition(R), tier span = matchCount(R).
|
||||
* (QF loss → 5/T5–8, SF loss → 3/T3–4, Finals loss → 2/finalist)
|
||||
* - Still alive, highest round won = R:
|
||||
* · R is the finalization round (winnerFloor null) → champion: winnerPosition ?? 1.
|
||||
* · otherwise → floor = winnerFloor(R), tier span = matchCount(R.feedsInto).
|
||||
* (QF win → 3/T3–4 = 9 QP, SF win → 2/finalist = 14 QP)
|
||||
* - Alive but no match resolved yet → entry floor of the first scoring round
|
||||
* (QF → 5/T5–8 = 4 QP), mirroring assignCs2EliminationQP's provisional floor.
|
||||
*
|
||||
* Pure and exported for unit testing. Teams eliminated in a non-scoring round
|
||||
* (getConfig returns null) are omitted — they earn no QP from the bracket.
|
||||
*/
|
||||
export function deriveBracketQualifyingStates(
|
||||
matches: BracketMatchInput[],
|
||||
rounds: BracketRound[],
|
||||
getConfig: (round: string) => RoundScoringConfig | null
|
||||
): Map<string, BracketQualifyingState> {
|
||||
const roundIndex = new Map(rounds.map((r, i) => [r.name, i]));
|
||||
const matchCountByRound = new Map(rounds.map((r) => [r.name, r.matchCount]));
|
||||
const feedsIntoByRound = new Map(rounds.map((r) => [r.name, r.feedsInto]));
|
||||
const firstScoringRound = rounds.find((r) => r.isScoring) ?? rounds[0];
|
||||
|
||||
const participants = new Set<string>();
|
||||
const wonRounds = new Map<string, Set<string>>();
|
||||
const lostRound = new Map<string, string>();
|
||||
|
||||
for (const m of matches) {
|
||||
if (m.participant1Id) participants.add(m.participant1Id);
|
||||
if (m.participant2Id) participants.add(m.participant2Id);
|
||||
if (m.winnerId) {
|
||||
participants.add(m.winnerId);
|
||||
if (!wonRounds.has(m.winnerId)) wonRounds.set(m.winnerId, new Set());
|
||||
wonRounds.get(m.winnerId)?.add(m.round);
|
||||
}
|
||||
if (m.loserId) {
|
||||
participants.add(m.loserId);
|
||||
lostRound.set(m.loserId, m.round);
|
||||
}
|
||||
}
|
||||
|
||||
const result = new Map<string, BracketQualifyingState>();
|
||||
|
||||
for (const id of participants) {
|
||||
const lost = lostRound.get(id);
|
||||
if (lost) {
|
||||
const cfg = getConfig(lost);
|
||||
if (!cfg) continue; // non-scoring elimination: no bracket QP
|
||||
result.set(id, { placement: cfg.loserPosition, tieCount: matchCountByRound.get(lost) ?? 1 });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Still alive: floor from the highest round they have won.
|
||||
const won = wonRounds.get(id);
|
||||
let highest: string | null = null;
|
||||
if (won) {
|
||||
for (const r of won) {
|
||||
if (highest === null || (roundIndex.get(r) ?? -1) > (roundIndex.get(highest) ?? -1)) {
|
||||
highest = r;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!highest) {
|
||||
// In the bracket but no match resolved yet → sitting in the first round.
|
||||
// Only grant an entry floor if that first round is itself a scoring round
|
||||
// (e.g. simple_8 / CS2 Champions Stage, where every entrant is already in
|
||||
// the scoring stage). For deep brackets whose scoring starts later
|
||||
// (tennis_128: Round of 128 → … → Round of 16), merely being drawn earns
|
||||
// nothing — no QP until the player reaches the first scoring round.
|
||||
if (!rounds[0]?.isScoring || !firstScoringRound) continue;
|
||||
const cfg = getConfig(firstScoringRound.name);
|
||||
if (!cfg) continue;
|
||||
result.set(id, { placement: cfg.loserPosition, tieCount: firstScoringRound.matchCount });
|
||||
continue;
|
||||
}
|
||||
|
||||
const cfg = getConfig(highest);
|
||||
if (!cfg) {
|
||||
// Won a non-scoring round. If that win advanced them INTO a scoring round,
|
||||
// they've guaranteed that round's loser floor (e.g. a tennis R32 win → in
|
||||
// the Round of 16 → guaranteed T9–16). Otherwise (won an earlier
|
||||
// non-scoring round) they've earned no QP yet.
|
||||
const feedsInto = feedsIntoByRound.get(highest) ?? null;
|
||||
const nextRound = feedsInto ? rounds.find((r) => r.name === feedsInto) : null;
|
||||
if (nextRound?.isScoring) {
|
||||
const floorCfg = getConfig(nextRound.name);
|
||||
if (floorCfg) {
|
||||
result.set(id, {
|
||||
placement: floorCfg.loserPosition,
|
||||
tieCount: nextRound.matchCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (cfg.winnerFloor === null) {
|
||||
// Won the finalization round → champion (or 3rd-place-game winner).
|
||||
result.set(id, { placement: cfg.winnerPosition ?? 1, tieCount: 1 });
|
||||
} else {
|
||||
const next = feedsIntoByRound.get(highest) ?? null;
|
||||
const tieCount = next ? (matchCountByRound.get(next) ?? 1) : 1;
|
||||
result.set(id, { placement: cfg.winnerFloor, tieCount });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a qualifying event whose Champions-Stage / knockout bracket lives in
|
||||
* playoffMatches (e.g. a CS2 Major). Derives each bracket team's guaranteed
|
||||
* minimum QP from the matches and upserts it into event_results, then refreshes
|
||||
* each participant's QP total.
|
||||
*
|
||||
* Unlike processPlayoffEvent (which writes the fantasy-points table
|
||||
* seasonParticipantResults), this writes ONLY the QP path. For a
|
||||
* qualifying_points sport the per-major fantasy placement is meaningless — final
|
||||
* fantasy placements come solely from finalizeQualifyingPoints across all majors.
|
||||
*
|
||||
* QP is written directly with the placement tier's STRUCTURAL tie span (QF tier
|
||||
* spans 4 slots, SF tier 2, finalist/champion 1) — not the live count of teams
|
||||
* currently at a placement. This is what makes a QF-winner floor 9 QP (avg of
|
||||
* 3rd+4th) even while all four QF winners transiently share placement 3.
|
||||
* processQualifyingEvent delegates to this function for bracket events precisely
|
||||
* so it does NOT re-group those rows by live count (which would average four
|
||||
* placement-3 rows down to 7).
|
||||
*
|
||||
* Idempotent: re-running on the same match state recomputes identical values.
|
||||
*/
|
||||
export async function processQualifyingBracketEvent(
|
||||
eventId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.id, eventId),
|
||||
});
|
||||
if (!event) throw new Error(`Event ${eventId} not found`);
|
||||
if (!event.isQualifyingEvent) {
|
||||
throw new Error(`Event ${eventId} is not a qualifying event`);
|
||||
}
|
||||
|
||||
const template = event.bracketTemplateId ? BRACKET_TEMPLATES[event.bracketTemplateId] : undefined;
|
||||
if (!template) {
|
||||
throw new Error(`Event ${eventId} has no bracket template; cannot derive qualifying bracket QP`);
|
||||
}
|
||||
|
||||
const matches = await db.query.playoffMatches.findMany({
|
||||
where: eq(schema.playoffMatches.scoringEventId, eventId),
|
||||
});
|
||||
|
||||
const states = deriveBracketQualifyingStates(
|
||||
matches.map((m) => ({
|
||||
round: m.round,
|
||||
winnerId: m.winnerId,
|
||||
loserId: m.loserId,
|
||||
participant1Id: m.participant1Id,
|
||||
participant2Id: m.participant2Id,
|
||||
})),
|
||||
template.rounds,
|
||||
(round) => getRoundConfig(round, event.bracketTemplateId)
|
||||
);
|
||||
|
||||
if (states.size === 0) return;
|
||||
|
||||
const qpConfigArray = await getQPConfig(event.sportsSeasonId, db);
|
||||
const qpMap = new Map<number, number>(
|
||||
qpConfigArray.map((c) => [c.placement, parseFloat(c.points)])
|
||||
);
|
||||
|
||||
const entries = new Map<string, { qp: number; placement: number }>(
|
||||
[...states.entries()].map(([id, { placement, tieCount }]) => [
|
||||
id,
|
||||
{ qp: calculateSplitQualifyingPoints(placement, tieCount, qpMap), placement },
|
||||
])
|
||||
);
|
||||
|
||||
await writeEventResultsQP(eventId, event.sportsSeasonId, entries, db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many results share each placement — the structural tie span used to
|
||||
* split QP across a tied group. Callers pass the FULL canonical field
|
||||
* (tournament_results) so the span reflects the whole tournament, not one window's
|
||||
* roster subset. Null placements (filler / not-participating) are ignored.
|
||||
*/
|
||||
export function buildTieCountByPlacement(
|
||||
results: Array<{ placement: number | null }>
|
||||
): Map<number, number> {
|
||||
const map = new Map<number, number>();
|
||||
for (const r of results) {
|
||||
if (r.placement === null) continue;
|
||||
map.set(r.placement, (map.get(r.placement) ?? 0) + 1);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a qualifying event completion and update QP totals.
|
||||
* Ties in QP are handled by sharing placements (averaged points).
|
||||
*/
|
||||
export async function processQualifyingEvent(
|
||||
eventId: string,
|
||||
providedDb?: ReturnType<typeof database>,
|
||||
options: {
|
||||
skipNotifications?: boolean;
|
||||
/**
|
||||
* Pre-computed full-field tie span (placement → count) from the canonical
|
||||
* tournament_results. When the fan-out already loaded the canonical results it
|
||||
* passes this in so we don't re-query per window. Omitted for direct callers,
|
||||
* which fall back to querying it here.
|
||||
*/
|
||||
canonicalTieCountByPlacement?: Map<number, number>;
|
||||
/**
|
||||
* This window's season_participant ids that were knocked out this sync in a
|
||||
* non-scoring round. They earn no QP (so they never surface via changed QP),
|
||||
* but a manager who drafted them should still be told. Threaded down from the
|
||||
* primary bracket by the fan-out (syncTournamentResults), already translated
|
||||
* to THIS window's season_participant ids. See the primary path in
|
||||
* app/services/match-sync/index.ts (newlyEliminatedIds).
|
||||
*/
|
||||
newlyEliminatedParticipantIds?: Set<string>;
|
||||
} = {}
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
|
||||
|
|
@ -1056,109 +652,55 @@ export async function processQualifyingEvent(
|
|||
// Get all event results for this qualifying event
|
||||
const results = await getEventResults(eventId, db);
|
||||
|
||||
// Snapshot awarded QP before reprocessing so the Discord notification below can
|
||||
// announce only the participants whose QP actually changed. Without this, a
|
||||
// sibling window re-scored on every fan-out sync (syncTournamentResults) would
|
||||
// re-ping the full QP standings each run even when nothing changed.
|
||||
const beforeQP = results.map((r) => ({
|
||||
id: r.seasonParticipantId,
|
||||
qp: r.qualifyingPointsAwarded,
|
||||
}));
|
||||
// Check if this was already processed (for majorsCompleted counter)
|
||||
const wasAlreadyProcessed = hasProcessedQualifyingPlacement(results);
|
||||
|
||||
// Route to the bracket writer only when this window actually OWNS a bracket (has
|
||||
// playoff matches). A window can carry a bracketTemplateId with no matches — e.g. a
|
||||
// league window cloned from a bracket season copies the template id but not the
|
||||
// matches (cloneSportsSeason) — and processQualifyingBracketEvent would derive zero
|
||||
// states and write NO QP. Those windows must be scored via the placement/canonical
|
||||
// path below, exactly like a no-template sibling.
|
||||
let hasBracketMatches = false;
|
||||
if (event.bracketTemplateId) {
|
||||
const existing = await db
|
||||
.select({ id: schema.playoffMatches.id })
|
||||
.from(schema.playoffMatches)
|
||||
.where(eq(schema.playoffMatches.scoringEventId, eventId))
|
||||
.limit(1);
|
||||
hasBracketMatches = existing.length > 0;
|
||||
// Clear stale QP before reprocessing so removed/null placements do not keep old awards.
|
||||
for (const result of results) {
|
||||
await db
|
||||
.update(schema.eventResults)
|
||||
.set({
|
||||
qualifyingPointsAwarded: "0",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.eventResults.id, result.id));
|
||||
}
|
||||
|
||||
if (hasBracketMatches) {
|
||||
// Bracket-based qualifying event (e.g. CS2 Champions Stage): QP is owned by the
|
||||
// bracket/stage writers, which assign each placement its STRUCTURAL tie span.
|
||||
// Re-derive via processQualifyingBracketEvent and leave the Swiss-exit rows
|
||||
// (written by assignCs2EliminationQP) untouched. This deliberately skips the
|
||||
// placement-regroup below — re-grouping provisional floors by their LIVE count
|
||||
// would average four placement-3 QF winners down to 7 QP instead of their 9 floor.
|
||||
await processQualifyingBracketEvent(eventId, db);
|
||||
} else {
|
||||
// Clear stale QP before reprocessing so removed/null placements do not keep old awards.
|
||||
for (const result of results) {
|
||||
const qpConfig = await getQPConfig(event.sportsSeasonId, db);
|
||||
const pointsByPlacement = new Map(
|
||||
qpConfig.map((config) => [config.placement, parseFloat(config.points)])
|
||||
);
|
||||
|
||||
// Group results by placement to handle ties
|
||||
const placementGroups = new Map<number, typeof results>();
|
||||
for (const result of results) {
|
||||
if (result.placement) {
|
||||
const group = placementGroups.get(result.placement) || [];
|
||||
group.push(result);
|
||||
placementGroups.set(result.placement, group);
|
||||
}
|
||||
}
|
||||
|
||||
// Process each placement group and update event_results with QP awarded
|
||||
for (const [placement, group] of placementGroups) {
|
||||
const tieCount = group.length;
|
||||
|
||||
const qpPerParticipant = calculateSplitQualifyingPoints(
|
||||
placement,
|
||||
tieCount,
|
||||
pointsByPlacement
|
||||
);
|
||||
|
||||
// Update the event result with the QP awarded
|
||||
for (const result of group) {
|
||||
await db
|
||||
.update(schema.eventResults)
|
||||
.set({
|
||||
qualifyingPointsAwarded: "0",
|
||||
qualifyingPointsAwarded: qpPerParticipant.toFixed(2),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.eventResults.id, result.id));
|
||||
}
|
||||
|
||||
const qpConfig = await getQPConfig(event.sportsSeasonId, db);
|
||||
const pointsByPlacement = new Map(
|
||||
qpConfig.map((config) => [config.placement, parseFloat(config.points)])
|
||||
);
|
||||
|
||||
// Full-field tie span. The number of players tied at a placement is a property
|
||||
// of the whole tournament field (canonical tournament_results), NOT of who
|
||||
// happens to be on THIS window's roster. Sibling/mirror windows only hold the
|
||||
// draftable subset of the field, so counting the placements present locally
|
||||
// (group.length) under-counts a tied group and over-awards it: tennis R16 losers
|
||||
// all sit at placement 9 with a structural span of 8 → (2+2+2+2+1+1+1+1)/8 = 1.5
|
||||
// QP; a window holding only 4 of them would wrongly split 4 ways → 2 QP. Deriving
|
||||
// the span from canonical results keeps every window/league identical. The fan-out
|
||||
// passes this map in (already loaded once per tournament); direct callers with a
|
||||
// tournament link query it here. Standalone events (no tournamentId, no map) have
|
||||
// no canonical field, so fall back to the live count.
|
||||
const canonicalTieCountByPlacement: Map<number, number> | null =
|
||||
options.canonicalTieCountByPlacement ??
|
||||
(event.tournamentId
|
||||
? buildTieCountByPlacement(
|
||||
await db
|
||||
.select({ placement: schema.tournamentResults.placement })
|
||||
.from(schema.tournamentResults)
|
||||
.where(eq(schema.tournamentResults.tournamentId, event.tournamentId))
|
||||
)
|
||||
: null);
|
||||
|
||||
// Group results by placement to handle ties
|
||||
const placementGroups = new Map<number, typeof results>();
|
||||
for (const result of results) {
|
||||
if (result.placement) {
|
||||
const group = placementGroups.get(result.placement) || [];
|
||||
group.push(result);
|
||||
placementGroups.set(result.placement, group);
|
||||
}
|
||||
}
|
||||
|
||||
// Process each placement group and update event_results with QP awarded
|
||||
for (const [placement, group] of placementGroups) {
|
||||
const tieCount = canonicalTieCountByPlacement?.get(placement) ?? group.length;
|
||||
|
||||
const qpPerParticipant = calculateSplitQualifyingPoints(
|
||||
placement,
|
||||
tieCount,
|
||||
pointsByPlacement
|
||||
);
|
||||
|
||||
// Update the event result with the QP awarded
|
||||
for (const result of group) {
|
||||
await db
|
||||
.update(schema.eventResults)
|
||||
.set({
|
||||
qualifyingPointsAwarded: qpPerParticipant.toFixed(2),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.eventResults.id, result.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate totals for all participants from scratch
|
||||
|
|
@ -1168,46 +710,21 @@ export async function processQualifyingEvent(
|
|||
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
||||
}
|
||||
|
||||
// NOTE: majorsCompleted is NOT a stored counter. It is derived on read via
|
||||
// getMajorsCompleted() (count of completed qualifying events). Incrementing here
|
||||
// per fan-out sync over-counted it past totalMajors ("11 of 4"), so the write was
|
||||
// removed. See app/models/scoring-event.ts:getMajorsCompleted.
|
||||
// Increment majorsCompleted counter (only if this is the first time processing)
|
||||
if (!wasAlreadyProcessed) {
|
||||
const sportsSeason = event.sportsSeason;
|
||||
await db
|
||||
.update(schema.sportsSeasons)
|
||||
.set({
|
||||
majorsCompleted: (sportsSeason.majorsCompleted || 0) + 1,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
|
||||
}
|
||||
|
||||
logger.log(
|
||||
`[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants`
|
||||
);
|
||||
|
||||
// Announce only participants whose awarded QP changed vs. the pre-reprocess
|
||||
// snapshot (a differing value, or a first-time score: null → value). This keeps
|
||||
// repeated fan-out syncs of an unchanged window from re-pinging the standings.
|
||||
const afterRows = await db.query.eventResults.findMany({
|
||||
where: eq(schema.eventResults.scoringEventId, eventId),
|
||||
});
|
||||
const changedParticipantIds = diffChangedQualifyingPoints(
|
||||
beforeQP,
|
||||
afterRows.map((r) => ({ id: r.seasonParticipantId, qp: r.qualifyingPointsAwarded }))
|
||||
);
|
||||
|
||||
// Players knocked out this sync in a non-scoring round earn no QP, so they never
|
||||
// appear in changedParticipantIds. Announce them too (mirroring the primary path
|
||||
// in syncTennisDraw), so a mirror window's "Knocked Out" section isn't dropped.
|
||||
const eliminatedIds = options.newlyEliminatedParticipantIds ?? new Set<string>();
|
||||
if (
|
||||
(changedParticipantIds.size > 0 || eliminatedIds.size > 0) &&
|
||||
!options.skipNotifications
|
||||
) {
|
||||
try {
|
||||
await notifyQualifyingPointsUpdate(
|
||||
event.sportsSeasonId,
|
||||
eventId,
|
||||
db,
|
||||
changedParticipantIds,
|
||||
eliminatedIds,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`[ScoringCalculator] QP Discord notification failed for event ${eventId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1481,7 +998,11 @@ export async function calculateTeamScore(
|
|||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
||||
}
|
||||
const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
columns: { bracketTemplateId: true },
|
||||
});
|
||||
const templateId = event?.bracketTemplateId ?? null;
|
||||
bracketTemplateCache.set(sportsSeasonId, templateId);
|
||||
return templateId;
|
||||
}
|
||||
|
|
@ -1590,7 +1111,11 @@ export async function calculateTeamProjectedScore(
|
|||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
||||
}
|
||||
const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
columns: { bracketTemplateId: true },
|
||||
});
|
||||
const templateId = event?.bracketTemplateId ?? null;
|
||||
bracketTemplateCache.set(sportsSeasonId, templateId);
|
||||
return templateId;
|
||||
}
|
||||
|
|
@ -1900,7 +1425,7 @@ export async function recalculateStandings(
|
|||
export async function recalculateAffectedLeagues(
|
||||
sportsSeasonId: string,
|
||||
providedDb?: ReturnType<typeof database>,
|
||||
options?: { eventName?: string; eventId?: string; matchIds?: string[]; skipDiscord?: boolean; eliminatedParticipantIds?: string[] }
|
||||
options?: { eventName?: string; eventId?: string; matchIds?: string[]; skipDiscord?: boolean }
|
||||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
|
||||
|
|
@ -1969,18 +1494,6 @@ export async function recalculateAffectedLeagues(
|
|||
for (const r of loserResults) finalizedLoserIds.add(r.participantId);
|
||||
}
|
||||
|
||||
// Look up names for participants eliminated by this action (e.g. bracket/knockout
|
||||
// generation). These produce no score delta, so they're surfaced via a dedicated
|
||||
// "Eliminated" section rather than the match/standings-change sections.
|
||||
const eliminatedIds = options?.eliminatedParticipantIds ?? [];
|
||||
const eliminatedNameById = new Map<string, string>();
|
||||
if (eliminatedIds.length > 0) {
|
||||
const eliminatedParticipants = await db.query.seasonParticipants.findMany({
|
||||
where: inArray(schema.seasonParticipants.id, eliminatedIds),
|
||||
});
|
||||
for (const p of eliminatedParticipants) eliminatedNameById.set(p.id, p.name);
|
||||
}
|
||||
|
||||
// Recalculate each affected season
|
||||
for (const seasonId of seasonIds) {
|
||||
// Capture standings before recalculation for delta calculation
|
||||
|
|
@ -2055,33 +1568,20 @@ export async function recalculateAffectedLeagues(
|
|||
afterStandings.map((s) => [s.teamId, s.team.ownerId])
|
||||
);
|
||||
|
||||
// Shared draft-pick lookup for both the scored-match and elimination sections,
|
||||
// loaded once per season only when at least one section needs it.
|
||||
const needDraftPicks = allCompletedMatches.length > 0 || eliminatedIds.length > 0;
|
||||
const draftPicks = needDraftPicks
|
||||
? await db.query.draftPicks.findMany({
|
||||
where: eq(schema.draftPicks.seasonId, seasonId),
|
||||
})
|
||||
: [];
|
||||
const draftedIds = new Set(draftPicks.map((p) => p.participantId));
|
||||
const teamIdByParticipantId = new Map(
|
||||
draftPicks.map((p) => [p.participantId, p.teamId])
|
||||
);
|
||||
|
||||
// Build scored matches for the notification.
|
||||
// A match is worth announcing when:
|
||||
// • the winner is owned by a manager AND earned points this round, OR
|
||||
// • the loser is owned by a manager (eliminated or scored).
|
||||
// A winning team that merely advanced through a non-scoring round (e.g. R32 → R16
|
||||
// in the World Cup) does not qualify on its own — their owner hasn't earned anything yet.
|
||||
// When a match qualifies because the loser is owned, the winner's manager tag is still
|
||||
// shown for context (who beat them), but the winner is not Discord-pinged.
|
||||
// Both managers' tags are always shown for context when their teams are drafted; the
|
||||
// showLoser flag (isLoserNotifiable) only gates whether the loser is @-pinged — a loser
|
||||
// who advanced rather than being eliminated (loserAdvances=true, e.g. NBA 7v8 → PIR2, or
|
||||
// a World Cup semifinal loser) is named but not pinged.
|
||||
// Winners only appear if their team's score changed (they earned points this round).
|
||||
// Losers appear if their team's score changed OR they were definitively eliminated
|
||||
// (finalPosition set, non-partial) — the latter catches 0-pt eliminations that
|
||||
// produce no score delta. Losers who advance to another match (loserAdvances=true,
|
||||
// e.g. NBA 7v8 → PIR2) have no finalized result and no score change, so they're
|
||||
// correctly suppressed.
|
||||
let scoredMatches: ScoredMatch[] | undefined;
|
||||
if (allCompletedMatches.length > 0) {
|
||||
const draftPicks = await db.query.draftPicks.findMany({
|
||||
where: eq(schema.draftPicks.seasonId, seasonId),
|
||||
});
|
||||
const draftedIds = new Set(draftPicks.map((p) => p.participantId));
|
||||
|
||||
const relevant = allCompletedMatches.filter(
|
||||
(m) =>
|
||||
(m.winnerId && draftedIds.has(m.winnerId)) ||
|
||||
|
|
@ -2089,6 +1589,10 @@ export async function recalculateAffectedLeagues(
|
|||
);
|
||||
|
||||
if (relevant.length > 0) {
|
||||
const teamIdByParticipantId = new Map(
|
||||
draftPicks.map((p) => [p.participantId, p.teamId])
|
||||
);
|
||||
|
||||
const lookupOwner = (participantId: string | null | undefined) => {
|
||||
if (!participantId) return undefined;
|
||||
const teamId = teamIdByParticipantId.get(participantId);
|
||||
|
|
@ -2105,52 +1609,23 @@ export async function recalculateAffectedLeagues(
|
|||
const winnerOwnerId = lookupOwner(m.winnerId);
|
||||
const loserOwnerId = lookupOwner(m.loserId);
|
||||
const showLoser = isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds);
|
||||
return { m, winnerScoreChanged, showLoser, winnerOwnerId, loserOwnerId };
|
||||
})
|
||||
.filter((x) => (x.winnerScoreChanged && !!x.winnerOwnerId) || (x.showLoser && !!x.loserOwnerId))
|
||||
.map((x) => ({
|
||||
winnerName: x.m.winnerName ?? "",
|
||||
loserName: x.m.loserName ?? "",
|
||||
winnerUsername: x.winnerOwnerId ? usernameByUserId.get(x.winnerOwnerId) : undefined,
|
||||
// Show the loser's manager tag whenever their team is drafted, mirroring the
|
||||
// winner above — even when the loser advances rather than being eliminated
|
||||
// (World Cup semifinal → 3rd-place playoff, AFL Qualifying Final → Semi Final).
|
||||
// The @-ping stays gated by showLoser (loserDiscordUserId below): a still-alive
|
||||
// loser who neither scored nor was eliminated is named for context but not pinged.
|
||||
loserUsername: x.loserOwnerId ? usernameByUserId.get(x.loserOwnerId) : undefined,
|
||||
winnerDiscordUserId: x.winnerScoreChanged && x.winnerOwnerId ? discordIdByUserId.get(x.winnerOwnerId) : undefined,
|
||||
loserDiscordUserId: x.showLoser && x.loserOwnerId ? discordIdByUserId.get(x.loserOwnerId) : undefined,
|
||||
}))
|
||||
.filter((m) => m.winnerUsername !== undefined || m.loserUsername !== undefined);
|
||||
return {
|
||||
winnerName: m.winnerName ?? "",
|
||||
loserName: m.loserName ?? "",
|
||||
winnerUsername: winnerScoreChanged && winnerOwnerId ? usernameByUserId.get(winnerOwnerId) : undefined,
|
||||
loserUsername: showLoser && loserOwnerId ? usernameByUserId.get(loserOwnerId) : undefined,
|
||||
winnerDiscordUserId: winnerScoreChanged && winnerOwnerId ? discordIdByUserId.get(winnerOwnerId) : undefined,
|
||||
loserDiscordUserId: showLoser && loserOwnerId ? discordIdByUserId.get(loserOwnerId) : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build the eliminated-teams section: drafted participants in this league that
|
||||
// were knocked out by this action (bracket/knockout generation). These have a
|
||||
// 0-pt result and no match, so they only surface here.
|
||||
let eliminatedTeams: EliminatedTeam[] | undefined;
|
||||
if (eliminatedIds.length > 0) {
|
||||
const teams = eliminatedIds
|
||||
.filter((id) => teamIdByParticipantId.has(id))
|
||||
.map((id) => {
|
||||
const teamId = teamIdByParticipantId.get(id);
|
||||
const ownerId = teamId ? ownerIdByTeamId.get(teamId) : undefined;
|
||||
return {
|
||||
participantName: eliminatedNameById.get(id) ?? "Unknown",
|
||||
username: ownerId ? usernameByUserId.get(ownerId) : undefined,
|
||||
discordUserId: ownerId ? discordIdByUserId.get(ownerId) : undefined,
|
||||
};
|
||||
});
|
||||
if (teams.length > 0) eliminatedTeams = teams;
|
||||
}
|
||||
|
||||
// Skip notification if scores didn't change AND no match has a displayable
|
||||
// username AND there's nothing eliminated to announce.
|
||||
// Skip notification if scores didn't change AND no match has a displayable username.
|
||||
const hasScoredMatchesToShow = scoredMatches?.some(
|
||||
(m) => m.winnerUsername !== undefined || m.loserUsername !== undefined
|
||||
);
|
||||
const hasEliminatedToShow = (eliminatedTeams?.length ?? 0) > 0;
|
||||
if (!hasChanges && !hasScoredMatchesToShow && !hasEliminatedToShow) continue;
|
||||
if (!hasChanges && !hasScoredMatchesToShow) continue;
|
||||
|
||||
const standings = afterStandings.map((s) => ({
|
||||
teamId: s.teamId,
|
||||
|
|
@ -2171,7 +1646,6 @@ export async function recalculateAffectedLeagues(
|
|||
sportName,
|
||||
eventName: options?.eventName,
|
||||
scoredMatches,
|
||||
eliminatedTeams,
|
||||
});
|
||||
} catch (err) {
|
||||
// Log but don't fail the scoring pipeline if Discord is unreachable
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event";
|
||||
|
||||
export function getEventTypeLabel(eventType: string): string {
|
||||
switch (eventType) {
|
||||
case "playoff_game": return "Bracket";
|
||||
case "major_tournament": return "Major Tournament";
|
||||
case "final_standings": return "Final Standings";
|
||||
case "schedule_event": return "Non-Scoring";
|
||||
default: return eventType;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,11 +3,20 @@ import * as schema from "~/database/schema";
|
|||
import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull, sql } from "drizzle-orm";
|
||||
import type { BracketRegion } from "~/lib/bracket-templates";
|
||||
import { recalculateAffectedLeagues } from "./scoring-calculator";
|
||||
import { recalculateParticipantQP } from "./qualifying-points";
|
||||
import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points";
|
||||
import { findParticipantNamesByIds } from "./season-participant";
|
||||
import { deleteTournament } from "./tournament";
|
||||
import type { EventType } from "./scoring-event-types";
|
||||
export { type EventType, getEventTypeLabel } from "./scoring-event-types";
|
||||
|
||||
export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event";
|
||||
|
||||
export function getEventTypeLabel(eventType: string): string {
|
||||
switch (eventType) {
|
||||
case "playoff_game": return "Bracket";
|
||||
case "major_tournament": return "Major Tournament";
|
||||
case "final_standings": return "Final Standings";
|
||||
case "schedule_event": return "Non-Scoring";
|
||||
default: return eventType;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateScoringEventData {
|
||||
sportsSeasonId: string;
|
||||
|
|
@ -33,8 +42,6 @@ export interface UpdateScoringEventData {
|
|||
scoringStartsAtRound?: string;
|
||||
/** Per-event region config for NCAA-style brackets (overrides template defaults) */
|
||||
bracketRegionConfig?: BracketRegion[] | null;
|
||||
/** External data-source locator (e.g. Wikipedia article title for tennis draws) */
|
||||
externalSourceKey?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -144,33 +151,6 @@ export async function getQualifyingEvents(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of "majors completed" for a sports season, derived on read as the count of
|
||||
* qualifying events that have been marked complete. Replaces the old stored
|
||||
* sportsSeasons.majorsCompleted counter, which was incremented per fan-out sync and
|
||||
* over-counted past totalMajors ("11 of 4"). Computing it makes the value
|
||||
* self-correcting and immune to double-counting.
|
||||
*/
|
||||
export async function getMajorsCompleted(
|
||||
sportsSeasonId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<number> {
|
||||
const db = providedDb || database();
|
||||
|
||||
const rows = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(schema.scoringEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
eq(schema.scoringEvents.isQualifyingEvent, true),
|
||||
eq(schema.scoringEvents.isComplete, true)
|
||||
)
|
||||
);
|
||||
|
||||
return rows[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a scoring event
|
||||
*/
|
||||
|
|
@ -193,7 +173,6 @@ export async function updateScoringEvent(
|
|||
if (data.bracketTemplateId !== undefined) updateData.bracketTemplateId = data.bracketTemplateId;
|
||||
if (data.scoringStartsAtRound !== undefined) updateData.scoringStartsAtRound = data.scoringStartsAtRound;
|
||||
if (data.bracketRegionConfig !== undefined) updateData.bracketRegionConfig = data.bracketRegionConfig;
|
||||
if (data.externalSourceKey !== undefined) updateData.externalSourceKey = data.externalSourceKey;
|
||||
|
||||
const [updated] = await db
|
||||
.update(schema.scoringEvents)
|
||||
|
|
@ -229,55 +208,28 @@ export async function completeScoringEvent(
|
|||
* sports season (placements are stored there with no FK back to the event) and
|
||||
* recalculates league standings.
|
||||
*/
|
||||
export interface DeleteScoringEventOptions {
|
||||
/**
|
||||
* When this was the last window linked to its shared tournament, also delete
|
||||
* the now-orphaned canonical tournament (and its results). No-op when other
|
||||
* windows remain. Off by default so deleting one season's event never touches
|
||||
* the shared tournament unless explicitly requested.
|
||||
*/
|
||||
deleteOrphanTournament?: boolean;
|
||||
}
|
||||
|
||||
export interface DeleteScoringEventResult {
|
||||
/** The shared tournament this event was linked to, if any. */
|
||||
tournamentId: string | null;
|
||||
/** How many other windows still link to that tournament after the delete. */
|
||||
remainingWindows: number;
|
||||
/** Event promoted to primary because the deleted event was the primary window. */
|
||||
promotedPrimaryId: string | null;
|
||||
/** Whether the orphaned canonical tournament was deleted. */
|
||||
deletedTournament: boolean;
|
||||
}
|
||||
|
||||
export async function deleteScoringEvent(
|
||||
eventId: string,
|
||||
providedDb?: ReturnType<typeof database>,
|
||||
opts?: DeleteScoringEventOptions
|
||||
): Promise<DeleteScoringEventResult> {
|
||||
providedDb?: ReturnType<typeof database>
|
||||
) {
|
||||
const db = providedDb || database();
|
||||
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.id, eventId),
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return {
|
||||
tournamentId: null,
|
||||
remainingWindows: 0,
|
||||
promotedPrimaryId: null,
|
||||
deletedTournament: false,
|
||||
};
|
||||
}
|
||||
if (!event) return;
|
||||
|
||||
// For qualifying events: capture affected participant IDs before the cascade deletes
|
||||
// eventResults (which is how we know who had QP awarded from this event).
|
||||
let affectedParticipantIds: string[] = [];
|
||||
let wasQPProcessed = false;
|
||||
if (event.isQualifyingEvent) {
|
||||
const results = await db.query.eventResults.findMany({
|
||||
where: eq(schema.eventResults.scoringEventId, eventId),
|
||||
});
|
||||
affectedParticipantIds = results.map((r) => r.seasonParticipantId);
|
||||
wasQPProcessed = hasProcessedQualifyingPlacement(results);
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
|
|
@ -300,49 +252,21 @@ export async function deleteScoringEvent(
|
|||
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
||||
}
|
||||
|
||||
// majorsCompleted is derived on read (getMajorsCompleted), so deleting the event
|
||||
// — which removes it from the completed-qualifying-event count — self-corrects the
|
||||
// number. No stored counter to decrement.
|
||||
// Decrement majorsCompleted if this event had already been processed
|
||||
if (wasQPProcessed) {
|
||||
const sportsSeason = await db.query.sportsSeasons.findFirst({
|
||||
where: eq(schema.sportsSeasons.id, event.sportsSeasonId),
|
||||
});
|
||||
if (sportsSeason && (sportsSeason.majorsCompleted ?? 0) > 0) {
|
||||
await db
|
||||
.update(schema.sportsSeasons)
|
||||
.set({ majorsCompleted: (sportsSeason.majorsCompleted ?? 1) - 1, updatedAt: new Date() })
|
||||
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
|
||||
}
|
||||
}
|
||||
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, db);
|
||||
}
|
||||
|
||||
// Shared-tournament bookkeeping: keep the primary window valid and optionally
|
||||
// clean up a canonical tournament left with no windows.
|
||||
let remainingWindows = 0;
|
||||
let promotedPrimaryId: string | null = null;
|
||||
let deletedTournament = false;
|
||||
|
||||
if (event.tournamentId) {
|
||||
const remaining = await db.query.scoringEvents.findMany({
|
||||
where: eq(schema.scoringEvents.tournamentId, event.tournamentId),
|
||||
orderBy: asc(schema.scoringEvents.createdAt),
|
||||
});
|
||||
remainingWindows = remaining.length;
|
||||
|
||||
if (remaining.length > 0) {
|
||||
// Auto-heal: if the deleted event was the primary window, promote the
|
||||
// earliest remaining window so the shared major stays scorable and
|
||||
// fan-out stays deterministic. Only heal when we actually removed the
|
||||
// primary — golf-style majors intentionally have no primary (scored on the
|
||||
// canonical tournament page), and event.isPrimary is false for them, so
|
||||
// they are left untouched.
|
||||
if (event.isPrimary) {
|
||||
await setPrimaryEvent(remaining[0].id, db);
|
||||
promotedPrimaryId = remaining[0].id;
|
||||
}
|
||||
} else if (opts?.deleteOrphanTournament) {
|
||||
await deleteTournament(event.tournamentId, db);
|
||||
deletedTournament = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tournamentId: event.tournamentId,
|
||||
remainingWindows,
|
||||
promotedPrimaryId,
|
||||
deletedTournament,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1143,112 +1067,3 @@ export async function getSportsSeasonsByTournament(tournamentId: string) {
|
|||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the distinct sports-season windows linked to a tournament. Cheaper than
|
||||
* getSportsSeasonsByTournament when only the count is needed (no relations).
|
||||
*/
|
||||
export async function countWindowsByTournament(
|
||||
tournamentId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<number> {
|
||||
const db = providedDb || database();
|
||||
const [row] = await db
|
||||
.select({
|
||||
count: sql<number>`count(distinct ${schema.scoringEvents.sportsSeasonId})::int`,
|
||||
})
|
||||
.from(schema.scoringEvents)
|
||||
.where(eq(schema.scoringEvents.tournamentId, tournamentId));
|
||||
return row?.count ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary scoring event for a tournament — the single window where the admin
|
||||
* builds the bracket/stages and scoring happens; its results fan out to siblings.
|
||||
* Returns the explicitly-flagged primary, falling back to the earliest-created
|
||||
* linked event so callers (league/sim loaders) always have a structure source
|
||||
* even before a primary is designated.
|
||||
*/
|
||||
export async function getPrimaryEventForTournament(
|
||||
tournamentId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
) {
|
||||
const db = providedDb || database();
|
||||
const primary = await db.query.scoringEvents.findFirst({
|
||||
where: and(
|
||||
eq(schema.scoringEvents.tournamentId, tournamentId),
|
||||
eq(schema.scoringEvents.isPrimary, true)
|
||||
),
|
||||
});
|
||||
if (primary) return primary;
|
||||
|
||||
return db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.tournamentId, tournamentId),
|
||||
orderBy: asc(schema.scoringEvents.createdAt),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A tournament-linked, non-primary event is a read-only mirror: its results are
|
||||
* owned by the shared major's primary window and fan out to it. Direct scoring
|
||||
* on such an event must be rejected so windows can't diverge. Single source of
|
||||
* truth for that predicate, shared by the admin route guards and the loaders.
|
||||
*/
|
||||
export function isReadOnlySibling(event: {
|
||||
tournamentId: string | null;
|
||||
isPrimary: boolean;
|
||||
}): boolean {
|
||||
return !!event.tournamentId && !event.isPrimary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make `eventId` the single primary window for its tournament — set it primary
|
||||
* and clear the flag on every sibling. Used by the "Make this the primary
|
||||
* window" admin action and to seed a primary when the first bracket-major event
|
||||
* is created.
|
||||
*/
|
||||
export async function setPrimaryEvent(
|
||||
eventId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
) {
|
||||
const db = providedDb || database();
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.id, eventId),
|
||||
});
|
||||
if (!event?.tournamentId) {
|
||||
throw new Error(`Event ${eventId} is not linked to a tournament`);
|
||||
}
|
||||
await db
|
||||
.update(schema.scoringEvents)
|
||||
.set({ isPrimary: false, updatedAt: new Date() })
|
||||
.where(eq(schema.scoringEvents.tournamentId, event.tournamentId));
|
||||
await db
|
||||
.update(schema.scoringEvents)
|
||||
.set({ isPrimary: true, updatedAt: new Date() })
|
||||
.where(eq(schema.scoringEvents.id, eventId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Designate `eventId` as the tournament's primary window only if none is set
|
||||
* yet. Idempotent: returns the existing primary's id when one already exists, so
|
||||
* creating/linking additional windows never steals primary from the first.
|
||||
*/
|
||||
export async function ensurePrimaryEvent(
|
||||
tournamentId: string,
|
||||
eventId: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<string> {
|
||||
const db = providedDb || database();
|
||||
const existing = await db.query.scoringEvents.findFirst({
|
||||
where: and(
|
||||
eq(schema.scoringEvents.tournamentId, tournamentId),
|
||||
eq(schema.scoringEvents.isPrimary, true)
|
||||
),
|
||||
});
|
||||
if (existing) return existing.id;
|
||||
await db
|
||||
.update(schema.scoringEvents)
|
||||
.set({ isPrimary: true, updatedAt: new Date() })
|
||||
.where(eq(schema.scoringEvents.id, eventId));
|
||||
return eventId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,21 +134,14 @@ export function calculateSharedPlacementPoints(
|
|||
* AFL is different: it has TWO distinct tiers in the 5–8 zone:
|
||||
* - T5-T6: Semi-Finals losers (positions 5 and 6) → avg([5,6])
|
||||
* - T7-T8: Elimination Finals losers (positions 7 and 8) → avg([7,8])
|
||||
*
|
||||
* LLWS has the same shape from its two elimination brackets:
|
||||
* - T5-T6: Elimination Final losers (one per side) → avg([5,6])
|
||||
* - T7-T8: Elimination Round 4 losers (one per side) → avg([7,8])
|
||||
*/
|
||||
const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10", "llws_20"]);
|
||||
const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10"]);
|
||||
|
||||
/**
|
||||
* Brackets with a real 3rd place game, meaning positions 3 and 4 are distinct
|
||||
* (not averaged). Standard brackets average them because both SF losers tie.
|
||||
*
|
||||
* llws_20's Consolation Third Place game decides 3rd and 4th head-to-head between
|
||||
* the two side runners-up.
|
||||
*/
|
||||
const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48", "llws_20"]);
|
||||
const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48"]);
|
||||
|
||||
/**
|
||||
* Calculate fantasy points for a bracket placement, averaging tied positions.
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
/**
|
||||
* Race-calendar state for season-standings sports (F1, IndyCar).
|
||||
*
|
||||
* Kept in its own leaf module rather than in `scoring-event.ts` so that
|
||||
* `simulator.ts` can read it: `scoring-event.ts` pulls in `scoring-calculator`,
|
||||
* which reaches `participant-expected-value` and back into `simulator`. This
|
||||
* file imports nothing but the database.
|
||||
*/
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
|
||||
export interface SeasonRaceCounts {
|
||||
completed: number;
|
||||
remaining: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long after the green flag a race is assumed to have finished.
|
||||
*
|
||||
* `event_starts_at` is a start time, so treating it as "already run" would
|
||||
* declare the season over the moment the finale goes green — and the simulator
|
||||
* would publish the pre-race leader as champion at 100%, from standings that do
|
||||
* not yet include the race being run. No race in these series comes close to
|
||||
* six hours, and the standings feed updates within hours of a finish.
|
||||
*/
|
||||
const RACE_DURATION_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Has this race already been run?
|
||||
*
|
||||
* `is_complete` wins when an admin has set it, but a racing calendar is stored
|
||||
* as "Non-Scoring" rows that nobody ever marks complete, so the date is the real
|
||||
* signal. Mirrors the Upcoming / Results Pending badge on the admin events page.
|
||||
* A race happening today is still upcoming, and a row with no date at all counts
|
||||
* as upcoming.
|
||||
*
|
||||
* @param today `now` as a `YYYY-MM-DD` string, to compare against the date-only
|
||||
* `event_date` column.
|
||||
*/
|
||||
export function hasRaceRun(
|
||||
event: {
|
||||
isComplete: boolean;
|
||||
eventDate: string | null;
|
||||
eventStartsAt: Date | string | null;
|
||||
},
|
||||
now: Date,
|
||||
today: string
|
||||
): boolean {
|
||||
if (event.isComplete) return true;
|
||||
if (event.eventStartsAt) {
|
||||
return new Date(event.eventStartsAt).getTime() + RACE_DURATION_MS < now.getTime();
|
||||
}
|
||||
if (event.eventDate) return event.eventDate < today;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the races on a season-standings calendar (F1, IndyCar).
|
||||
*
|
||||
* `event_type` has no race value, so a racing calendar is stored as
|
||||
* `schedule_event` rows — the admin default for the `season_standings` scoring
|
||||
* pattern. The only other row such a season carries is the single
|
||||
* `final_standings` event that assigns fantasy placements once the championship
|
||||
* is settled. A race is therefore "every event except `final_standings`", not
|
||||
* "every event except `schedule_event`" — getting that backwards leaves the
|
||||
* simulator with zero remaining races and no idea the season is in progress.
|
||||
*/
|
||||
export async function countSeasonRaces(
|
||||
sportsSeasonId: string,
|
||||
now: Date = new Date(),
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<SeasonRaceCounts> {
|
||||
const db = providedDb || database();
|
||||
|
||||
const events = await db.query.scoringEvents.findMany({
|
||||
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
columns: {
|
||||
eventType: true,
|
||||
isComplete: true,
|
||||
eventDate: true,
|
||||
eventStartsAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const today = now.toISOString().split("T")[0];
|
||||
let completed = 0;
|
||||
let remaining = 0;
|
||||
|
||||
for (const event of events) {
|
||||
if (event.eventType === "final_standings") continue;
|
||||
if (hasRaceRun(event, now, today)) completed++;
|
||||
else remaining++;
|
||||
}
|
||||
|
||||
return { completed, remaining, total: completed + remaining };
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { and, eq, inArray, isNotNull, sql } from "drizzle-orm";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { database } from "~/database/context";
|
||||
import * as schema from "~/database/schema";
|
||||
import {
|
||||
|
|
@ -8,17 +8,12 @@ import {
|
|||
type SimulatorManifestProfile,
|
||||
} from "~/services/simulations/manifest";
|
||||
import {
|
||||
getSimulatorInputPolicy,
|
||||
ratingRequirementLabel,
|
||||
resolveRatings,
|
||||
resolveSourceElos,
|
||||
sourceEloRequirementLabel,
|
||||
} from "~/services/simulations/input-policy";
|
||||
import { SIMULATOR_TYPES, type SimulatorType } from "~/services/simulations/registry";
|
||||
import { countSeasonRaces } from "~/models/season-races";
|
||||
|
||||
/** Simulator types driven by a race calendar plus championship standings. */
|
||||
const RACE_CALENDAR_SIMULATORS: SimulatorType[] = ["f1_standings", "indycar_standings"];
|
||||
|
||||
export interface SimulatorProfile extends SimulatorManifestProfile {
|
||||
isActive: boolean;
|
||||
|
|
@ -87,12 +82,6 @@ export interface SportsSeasonSimulatorSummary {
|
|||
participantInputCount: number;
|
||||
lastSimulatedDate: string | null;
|
||||
readiness: SimulatorReadiness;
|
||||
/** Whether this simulator exposes a Futures Odds setup section. */
|
||||
supportsFuturesOdds: boolean;
|
||||
/** Number of participants that currently have stored futures odds. */
|
||||
oddsParticipantCount: number;
|
||||
/** Weight (0–1) the season's policy gives futures odds when blending into Elo. */
|
||||
oddsWeight: number;
|
||||
}
|
||||
|
||||
function parseDecimal(value: string | null | undefined): number | null {
|
||||
|
|
@ -136,8 +125,8 @@ function isFallbackMethod(method: string): boolean {
|
|||
return method === "fallbackElo" || method === "fallbackRating" || method === "averageKnown" || method === "worstKnownMinus";
|
||||
}
|
||||
|
||||
const GENERATED_RATING_METHODS = ["sourceOdds", "blend", "fallbackRating", "averageKnown", "worstKnownMinus"] as const;
|
||||
const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "blend", "fallbackElo", "averageKnown", "worstKnownMinus"] as const;
|
||||
const GENERATED_RATING_METHODS = ["sourceOdds", "fallbackRating", "averageKnown", "worstKnownMinus"] as const;
|
||||
const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "fallbackElo", "averageKnown", "worstKnownMinus"] as const;
|
||||
|
||||
function isGeneratedRatingMethod(method: unknown): boolean {
|
||||
return typeof method === "string" && GENERATED_RATING_METHODS.includes(method as (typeof GENERATED_RATING_METHODS)[number]);
|
||||
|
|
@ -311,6 +300,112 @@ export async function getParticipantSimulatorInputs(
|
|||
});
|
||||
}
|
||||
|
||||
function generatedRatingMethodSql() {
|
||||
return sql`${schema.seasonParticipantSimulatorInputs.metadata}->>'ratingMethod' in ('sourceOdds', 'fallbackRating', 'averageKnown', 'worstKnownMinus')`;
|
||||
}
|
||||
|
||||
export async function batchSaveParticipantSimulatorSourceOdds(
|
||||
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }>
|
||||
): Promise<void> {
|
||||
if (inputs.length === 0) return;
|
||||
const db = database();
|
||||
const now = new Date();
|
||||
const seasonIds = [...new Set(inputs.map((input) => input.sportsSeasonId))];
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(schema.seasonParticipantSimulatorInputs)
|
||||
.set({
|
||||
rating: null,
|
||||
metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds),
|
||||
generatedRatingMethodSql()
|
||||
)
|
||||
);
|
||||
|
||||
await tx
|
||||
.insert(schema.seasonParticipantSimulatorInputs)
|
||||
.values(
|
||||
inputs.map((input) => ({
|
||||
participantId: input.participantId,
|
||||
sportsSeasonId: input.sportsSeasonId,
|
||||
sourceOdds: input.sourceOdds,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
)
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
schema.seasonParticipantSimulatorInputs.participantId,
|
||||
schema.seasonParticipantSimulatorInputs.sportsSeasonId,
|
||||
],
|
||||
set: {
|
||||
sourceOdds: sql`excluded.source_odds`,
|
||||
rating: sql`case when ${generatedRatingMethodSql()} then null else ${schema.seasonParticipantSimulatorInputs.rating} end`,
|
||||
metadata: sql`case when ${generatedRatingMethodSql()} then coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod' else ${schema.seasonParticipantSimulatorInputs.metadata} end`,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function batchSaveFuturesOddsForSimulator(
|
||||
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }>
|
||||
): Promise<void> {
|
||||
if (inputs.length === 0) return;
|
||||
const db = database();
|
||||
const now = new Date();
|
||||
const participantIds = inputs.map((input) => input.participantId);
|
||||
const seasonIds = [...new Set(inputs.map((input) => input.sportsSeasonId))];
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
|
||||
// Clear ALL ratings (both manual and generated) so the simulation
|
||||
// re-derives ratings from the newly saved futures odds.
|
||||
await tx
|
||||
.update(schema.seasonParticipantSimulatorInputs)
|
||||
.set({
|
||||
rating: null,
|
||||
metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds),
|
||||
inArray(schema.seasonParticipantSimulatorInputs.participantId, participantIds)
|
||||
)
|
||||
);
|
||||
|
||||
await tx
|
||||
.insert(schema.seasonParticipantSimulatorInputs)
|
||||
.values(
|
||||
inputs.map((input) => ({
|
||||
participantId: input.participantId,
|
||||
sportsSeasonId: input.sportsSeasonId,
|
||||
sourceOdds: input.sourceOdds,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
)
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
schema.seasonParticipantSimulatorInputs.participantId,
|
||||
schema.seasonParticipantSimulatorInputs.sportsSeasonId,
|
||||
],
|
||||
set: {
|
||||
sourceOdds: sql`excluded.source_odds`,
|
||||
rating: null,
|
||||
metadata: sql`coalesce(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb) - 'ratingMethod'`,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function batchUpsertParticipantSimulatorInputs(
|
||||
inputs: UpsertParticipantSimulatorInput[]
|
||||
): Promise<void> {
|
||||
|
|
@ -343,44 +438,16 @@ export async function batchUpsertParticipantSimulatorInputs(
|
|||
schema.seasonParticipantSimulatorInputs.participantId,
|
||||
schema.seasonParticipantSimulatorInputs.sportsSeasonId,
|
||||
],
|
||||
// Non-destructive: only overwrite a column when the incoming value is
|
||||
// non-null. This lets a partial import (e.g. odds-only) update just the
|
||||
// columns it provides without clobbering previously stored inputs like a
|
||||
// manually entered Elo or rating. Mirrors the COALESCE bridge used for the
|
||||
// legacy EV table below.
|
||||
set: {
|
||||
sourceOdds: sql`COALESCE(excluded.source_odds, ${schema.seasonParticipantSimulatorInputs.sourceOdds})`,
|
||||
sourceElo: sql`COALESCE(excluded.source_elo, ${schema.seasonParticipantSimulatorInputs.sourceElo})`,
|
||||
worldRanking: sql`COALESCE(excluded.world_ranking, ${schema.seasonParticipantSimulatorInputs.worldRanking})`,
|
||||
rating: sql`COALESCE(excluded.rating, ${schema.seasonParticipantSimulatorInputs.rating})`,
|
||||
projectedWins: sql`COALESCE(excluded.projected_wins, ${schema.seasonParticipantSimulatorInputs.projectedWins})`,
|
||||
projectedTablePoints: sql`COALESCE(excluded.projected_table_points, ${schema.seasonParticipantSimulatorInputs.projectedTablePoints})`,
|
||||
seed: sql`COALESCE(excluded.seed, ${schema.seasonParticipantSimulatorInputs.seed})`,
|
||||
region: sql`COALESCE(excluded.region, ${schema.seasonParticipantSimulatorInputs.region})`,
|
||||
// Metadata carries the method flags (sourceEloMethod/ratingMethod) that
|
||||
// tell readers whether the stored Elo/rating is generated vs. a trusted
|
||||
// direct value. Two rules apply, and both always apply — they are not
|
||||
// alternatives:
|
||||
//
|
||||
// 1. Drop the method flag for any column receiving a fresh direct
|
||||
// value, otherwise a stale "generated" flag would cause that
|
||||
// newly-entered Elo/rating to be filtered out as derived (see
|
||||
// getParticipantSimulatorInputs).
|
||||
// 2. Merge any metadata the caller supplied over the result
|
||||
// (prepareSimulatorInputsForRun and the projection importers set the
|
||||
// correct flags) — a merge rather than a replace so a caller that
|
||||
// only needs to stamp one method flag does not wipe unrelated keys.
|
||||
//
|
||||
// Ordering matters: strip first, then merge, so a caller stamping one flag
|
||||
// still gets the other column's stale flag cleared. Running these as
|
||||
// exclusive CASE branches instead would mean a bulk row carrying both a
|
||||
// direct `rating` and a `projectedWins` (which stamps sourceEloMethod)
|
||||
// silently kept a stale ratingMethod, hiding the rating it just set.
|
||||
metadata: sql`(
|
||||
COALESCE(${schema.seasonParticipantSimulatorInputs.metadata}, '{}'::jsonb)
|
||||
- (CASE WHEN excluded.source_elo IS NOT NULL THEN 'sourceEloMethod' ELSE '' END)
|
||||
- (CASE WHEN excluded.rating IS NOT NULL THEN 'ratingMethod' ELSE '' END)
|
||||
) || COALESCE(excluded.metadata, '{}'::jsonb)`,
|
||||
sourceOdds: sql`excluded.source_odds`,
|
||||
sourceElo: sql`excluded.source_elo`,
|
||||
worldRanking: sql`excluded.world_ranking`,
|
||||
rating: sql`excluded.rating`,
|
||||
projectedWins: sql`excluded.projected_wins`,
|
||||
projectedTablePoints: sql`excluded.projected_table_points`,
|
||||
seed: sql`excluded.seed`,
|
||||
region: sql`excluded.region`,
|
||||
metadata: sql`excluded.metadata`,
|
||||
updatedAt: sql`excluded.updated_at`,
|
||||
},
|
||||
});
|
||||
|
|
@ -502,19 +569,6 @@ export async function validateSimulatorReadiness(
|
|||
}
|
||||
}
|
||||
|
||||
if (RACE_CALENDAR_SIMULATORS.includes(config.simulatorType)) {
|
||||
// Without a calendar the simulator cannot tell how many races are left, so
|
||||
// it falls back to futures odds and ignores the championship standings
|
||||
// entirely. A warning, not a blocker — a season drafted before the schedule
|
||||
// is published still needs to run.
|
||||
const races = await countSeasonRaces(sportsSeasonId);
|
||||
if (races.total === 0) {
|
||||
warnings.push(
|
||||
"No race calendar found for this season. Add the schedule on the events page — until then the simulation uses futures odds only and ignores championship standings."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (config.profile.setupSections.includes("regularStandings")) {
|
||||
warnings.push("Regular-season standings may be needed for in-season accuracy.");
|
||||
}
|
||||
|
|
@ -609,23 +663,6 @@ export async function listSportsSeasonSimulatorSummaries(): Promise<SportsSeason
|
|||
: [];
|
||||
const lastSnapshotBySeason = new Map(snapshotRows.map((row) => [row.sportsSeasonId, row.lastSimulatedDate]));
|
||||
|
||||
const oddsCountRows = seasonIds.length > 0
|
||||
? await db
|
||||
.select({
|
||||
sportsSeasonId: schema.seasonParticipantSimulatorInputs.sportsSeasonId,
|
||||
oddsCount: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(schema.seasonParticipantSimulatorInputs)
|
||||
.where(
|
||||
and(
|
||||
inArray(schema.seasonParticipantSimulatorInputs.sportsSeasonId, seasonIds),
|
||||
isNotNull(schema.seasonParticipantSimulatorInputs.sourceOdds)
|
||||
)
|
||||
)
|
||||
.groupBy(schema.seasonParticipantSimulatorInputs.sportsSeasonId)
|
||||
: [];
|
||||
const oddsCountBySeason = new Map(oddsCountRows.map((row) => [row.sportsSeasonId, Number(row.oddsCount)]));
|
||||
|
||||
// Each season calls getSimulatorProfile + validateSimulatorReadiness (~4 queries each).
|
||||
// Acceptable for an admin-only page; revisit if season counts grow past ~50.
|
||||
const summaries = await Promise.all(
|
||||
|
|
@ -633,8 +670,6 @@ export async function listSportsSeasonSimulatorSummaries(): Promise<SportsSeason
|
|||
const simulatorType = (season.simulatorConfig?.simulatorType ?? season.sport.simulatorType) as SimulatorType;
|
||||
const profile = await getSimulatorProfile(simulatorType);
|
||||
const readiness = await validateSimulatorReadiness(season.id);
|
||||
const mergedConfig = { ...profile.defaultConfig, ...season.simulatorConfig?.config };
|
||||
const policy = getSimulatorInputPolicy(mergedConfig);
|
||||
return {
|
||||
sportsSeasonId: season.id,
|
||||
seasonName: season.name,
|
||||
|
|
@ -652,9 +687,6 @@ export async function listSportsSeasonSimulatorSummaries(): Promise<SportsSeason
|
|||
participantInputCount: readiness.participantInputCount,
|
||||
lastSimulatedDate: lastSnapshotBySeason.get(season.id) ?? null,
|
||||
readiness,
|
||||
supportsFuturesOdds: profile.setupSections.includes("futuresOdds"),
|
||||
oddsParticipantCount: oddsCountBySeason.get(season.id) ?? 0,
|
||||
oddsWeight: policy.oddsWeight,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -169,57 +169,6 @@ export async function findDraftableSportsSeasonBySportId(sportId: string) {
|
|||
});
|
||||
}
|
||||
|
||||
export type DraftScheduleWindow = {
|
||||
id: string;
|
||||
name: string;
|
||||
year: number;
|
||||
status: SportsSeasonStatus;
|
||||
draftOn: string;
|
||||
draftOff: string;
|
||||
sport: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
iconUrl: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns admin sport-seasons (fantasySeasonId IS NULL) whose draft window
|
||||
* [draftOn, draftOff] overlaps the horizon [today, today + monthsAhead]. Uses an
|
||||
* overlap test (not containment) so windows already open, or spanning the horizon
|
||||
* edges, are still included. Powers the admin draft-schedule Gantt chart.
|
||||
*/
|
||||
export async function findDraftScheduleForHorizon(
|
||||
monthsAhead: number
|
||||
): Promise<DraftScheduleWindow[]> {
|
||||
const db = database();
|
||||
const horizonEnd = sql`CURRENT_DATE + make_interval(months => ${monthsAhead})`;
|
||||
const seasons = await db.query.sportsSeasons.findMany({
|
||||
where: (ss, { and }) =>
|
||||
and(
|
||||
isNull(ss.fantasySeasonId),
|
||||
gte(ss.draftOff, sql`CURRENT_DATE`),
|
||||
lte(ss.draftOn, horizonEnd)
|
||||
),
|
||||
orderBy: (sportsSeasons, { asc }) => [asc(sportsSeasons.draftOn)],
|
||||
with: {
|
||||
sport: {
|
||||
columns: { id: true, name: true, slug: true, iconUrl: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return seasons.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
year: s.year,
|
||||
status: s.status,
|
||||
draftOn: s.draftOn,
|
||||
draftOff: s.draftOff,
|
||||
sport: s.sport,
|
||||
})) as DraftScheduleWindow[];
|
||||
}
|
||||
|
||||
export async function updateSportsSeason(
|
||||
id: string,
|
||||
data: Partial<NewSportsSeason>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from
|
|||
import { calculateBracketPoints, calculateFantasyPoints } from "~/models/scoring-rules";
|
||||
import { logger } from "~/lib/logger";
|
||||
import { getParticipantEV } from "./participant-expected-value";
|
||||
import { getBracketTemplateIdForSportsSeason } from "./bracket-template";
|
||||
import { calculateEV } from "~/services/ev-calculator";
|
||||
|
||||
// Re-export types from shared types file
|
||||
|
|
@ -164,7 +163,11 @@ export async function getTeamScoreBreakdown(
|
|||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
||||
}
|
||||
const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
|
||||
const event = await db.query.scoringEvents.findFirst({
|
||||
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||||
columns: { bracketTemplateId: true },
|
||||
});
|
||||
const templateId = event?.bracketTemplateId ?? null;
|
||||
bracketTemplateCache.set(sportsSeasonId, templateId);
|
||||
return templateId;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,17 +136,3 @@ export async function updateTournamentStatus(
|
|||
.returning();
|
||||
return tournament;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a canonical tournament. The DB cascades remove its tournament_results;
|
||||
* any surviving scoring_events have their tournamentId set to NULL (onDelete:
|
||||
* "set null"). Callers should only use this once the last linked window has been
|
||||
* removed, so no scoring events are left pointing at it.
|
||||
*/
|
||||
export async function deleteTournament(
|
||||
id: string,
|
||||
providedDb?: ReturnType<typeof database>
|
||||
): Promise<void> {
|
||||
const db = providedDb || database();
|
||||
await db.delete(schema.tournaments).where(eq(schema.tournaments.id, id));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export default [
|
|||
route("forgot-password", "routes/forgot-password.tsx"),
|
||||
route("reset-password", "routes/reset-password.tsx"),
|
||||
route("onboarding", "routes/onboarding.tsx"),
|
||||
route("settings/:section?", "routes/settings.tsx"),
|
||||
route("settings", "routes/settings.tsx"),
|
||||
route("user-profile", "routes/user-profile-redirect.tsx"),
|
||||
route("how-to-play", "routes/how-to-play.tsx"),
|
||||
route("rules", "routes/rules.tsx"),
|
||||
|
|
@ -96,7 +96,6 @@ export default [
|
|||
route("simulators", "routes/admin.simulators.tsx"),
|
||||
route("sports/new", "routes/admin.sports.new.tsx"),
|
||||
route("sports/:id", "routes/admin.sports.$id.tsx"),
|
||||
route("draft-schedule", "routes/admin.draft-schedule.tsx"),
|
||||
route("sports-seasons", "routes/admin.sports-seasons.tsx"),
|
||||
route("sports-seasons/new", "routes/admin.sports-seasons.new.tsx"),
|
||||
route("sports-seasons/:id", "routes/admin.sports-seasons.$id.tsx"),
|
||||
|
|
|
|||
|
|
@ -1,64 +0,0 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* The Expected Values admin page renders EV from the stored probability columns.
|
||||
*
|
||||
* It used to carry its own hardcoded scoring table (100/70/45/45/20/20/20/20), which
|
||||
* flattened positions 5–8 to 20 points each. For a standard single-elimination bracket
|
||||
* that was invisible — all four quarterfinal losers share one tier worth
|
||||
* avg(25,25,15,15) = 20 anyway — but for the templates that split 5–8 into two tiers
|
||||
* (llws_20, afl_10) it reported a team locked into 5th–6th and a team locked into
|
||||
* 7th–8th as the same 20 points. These pin it to the shared DEFAULT_SCORING_RULES.
|
||||
*/
|
||||
|
||||
vi.mock("../admin.sports-seasons.$id.expected-values.server", () => ({
|
||||
loader: vi.fn(),
|
||||
}));
|
||||
|
||||
import { evFromProbs } from "../admin.sports-seasons.$id.expected-values";
|
||||
|
||||
const ZERO = {
|
||||
probFirst: "0", probSecond: "0", probThird: "0", probFourth: "0",
|
||||
probFifth: "0", probSixth: "0", probSeventh: "0", probEighth: "0",
|
||||
};
|
||||
|
||||
describe("evFromProbs", () => {
|
||||
it("gives a team locked into the 5th–6th tier 25 points, not 20", () => {
|
||||
expect(evFromProbs({ ...ZERO, probFifth: "0.5", probSixth: "0.5" })).toBe(25);
|
||||
});
|
||||
|
||||
it("gives a team locked into the 7th–8th tier 15 points, not 20", () => {
|
||||
expect(evFromProbs({ ...ZERO, probSeventh: "0.5", probEighth: "0.5" })).toBe(15);
|
||||
});
|
||||
|
||||
it("still gives a single 5th–8th tier (4 QF losers) 20 points", () => {
|
||||
const ev = evFromProbs({
|
||||
...ZERO,
|
||||
probFifth: "0.25", probSixth: "0.25", probSeventh: "0.25", probEighth: "0.25",
|
||||
});
|
||||
expect(ev).toBe(20);
|
||||
});
|
||||
|
||||
it("keeps 3rd and 4th distinct rather than a flat 45 each", () => {
|
||||
expect(evFromProbs({ ...ZERO, probThird: "1" })).toBe(50);
|
||||
expect(evFromProbs({ ...ZERO, probFourth: "1" })).toBe(40);
|
||||
});
|
||||
|
||||
it("preserves the 340 total-EV invariant across a full set of unit columns", () => {
|
||||
const perPosition = [
|
||||
evFromProbs({ ...ZERO, probFirst: "1" }),
|
||||
evFromProbs({ ...ZERO, probSecond: "1" }),
|
||||
evFromProbs({ ...ZERO, probThird: "1" }),
|
||||
evFromProbs({ ...ZERO, probFourth: "1" }),
|
||||
evFromProbs({ ...ZERO, probFifth: "1" }),
|
||||
evFromProbs({ ...ZERO, probSixth: "1" }),
|
||||
evFromProbs({ ...ZERO, probSeventh: "1" }),
|
||||
evFromProbs({ ...ZERO, probEighth: "1" }),
|
||||
];
|
||||
expect(perPosition.reduce((sum, ev) => sum + ev, 0)).toBe(340);
|
||||
});
|
||||
|
||||
it("returns 0 for a participant with no probability mass", () => {
|
||||
expect(evFromProbs(ZERO)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
parseBaseEloPriorityChoice,
|
||||
projectionMethodMetadata,
|
||||
resolvedInputMethodLabel,
|
||||
} from "../admin.sports-seasons.$id.simulator.helpers";
|
||||
import { DEFAULT_BASE_ELO_PRIORITY } from "~/services/simulations/input-policy";
|
||||
|
||||
describe("projectionMethodMetadata", () => {
|
||||
it("flags a row that supplies projected wins and no Elo", () => {
|
||||
expect(projectionMethodMetadata(undefined, 95, undefined)).toEqual({
|
||||
sourceEloMethod: "projectedWins",
|
||||
});
|
||||
});
|
||||
|
||||
it("flags a row that supplies projected table points and no Elo", () => {
|
||||
expect(projectionMethodMetadata(undefined, undefined, 76.5)).toEqual({
|
||||
sourceEloMethod: "projectedTablePoints",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves metadata alone when the row supplies an explicit Elo", () => {
|
||||
// An explicit Elo is a direct entry and must stay trusted, even alongside a
|
||||
// projection — the upsert then clears any stale generated flag.
|
||||
expect(projectionMethodMetadata(1600, 95, undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves metadata alone for a row with neither", () => {
|
||||
expect(projectionMethodMetadata(undefined, undefined, undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("prefers wins over table points when a row somehow carries both", () => {
|
||||
expect(projectionMethodMetadata(undefined, 95, 76.5)).toEqual({
|
||||
sourceEloMethod: "projectedWins",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBaseEloPriorityChoice", () => {
|
||||
it("puts projections ahead of raw Elo", () => {
|
||||
expect(parseBaseEloPriorityChoice("projectionsFirst", DEFAULT_BASE_ELO_PRIORITY)).toEqual([
|
||||
"projectedWins",
|
||||
"projectedTablePoints",
|
||||
"sourceElo",
|
||||
]);
|
||||
});
|
||||
|
||||
it("puts raw Elo first for eloFirst", () => {
|
||||
expect(parseBaseEloPriorityChoice("eloFirst", DEFAULT_BASE_ELO_PRIORITY)).toEqual(
|
||||
DEFAULT_BASE_ELO_PRIORITY
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the stored ordering when the select was not on the form", () => {
|
||||
// Simulators with no projection alternative never render the control; saving
|
||||
// other config must not rewrite their ordering.
|
||||
const custom: typeof DEFAULT_BASE_ELO_PRIORITY = ["projectedWins", "sourceElo"];
|
||||
expect(parseBaseEloPriorityChoice(null, custom)).toEqual(custom);
|
||||
});
|
||||
|
||||
it("preserves the relative order of the projection keys", () => {
|
||||
expect(
|
||||
parseBaseEloPriorityChoice("projectionsFirst", [
|
||||
"projectedTablePoints",
|
||||
"sourceElo",
|
||||
"projectedWins",
|
||||
])
|
||||
).toEqual(["projectedTablePoints", "projectedWins", "sourceElo"]);
|
||||
});
|
||||
|
||||
it("round-trips: flipping back restores Elo-first", () => {
|
||||
const flipped = parseBaseEloPriorityChoice("projectionsFirst", DEFAULT_BASE_ELO_PRIORITY);
|
||||
expect(parseBaseEloPriorityChoice("eloFirst", flipped)).toEqual(DEFAULT_BASE_ELO_PRIORITY);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvedInputMethodLabel", () => {
|
||||
it("badges nothing for a directly entered Elo or rating", () => {
|
||||
expect(resolvedInputMethodLabel("direct")).toBeNull();
|
||||
});
|
||||
|
||||
it("badges both projection methods the same way", () => {
|
||||
expect(resolvedInputMethodLabel("projectedWins")).toBe("from projections");
|
||||
expect(resolvedInputMethodLabel("projectedTablePoints")).toBe("from projections");
|
||||
});
|
||||
|
||||
it("distinguishes futures and blended Elo", () => {
|
||||
expect(resolvedInputMethodLabel("sourceOdds")).toBe("from futures");
|
||||
expect(resolvedInputMethodLabel("blend")).toBe("blended");
|
||||
});
|
||||
|
||||
it("badges every missing-input strategy as a fallback", () => {
|
||||
expect(resolvedInputMethodLabel("fallbackElo")).toBe("fallback");
|
||||
expect(resolvedInputMethodLabel("fallbackRating")).toBe("fallback");
|
||||
expect(resolvedInputMethodLabel("averageKnown")).toBe("fallback");
|
||||
expect(resolvedInputMethodLabel("worstKnownMinus")).toBe("fallback");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
/**
|
||||
* clear-bracket is the only path that can tear down a bracket, so the guard around it
|
||||
* matters: it discards recorded results and the placements derived from them.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
findPlayoffMatchesByEventId,
|
||||
deletePlayoffMatchesByEventId,
|
||||
} from "~/models/playoff-match";
|
||||
import { deleteParticipantResultsBySportsSeasonId } from "~/models/participant-result";
|
||||
import { recalculateAffectedLeagues } from "~/models/scoring-calculator";
|
||||
import { getScoringEventById } from "~/models/scoring-event";
|
||||
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
|
||||
|
||||
vi.mock("~/models/scoring-event", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
getScoringEventById: vi.fn(),
|
||||
updateScoringEvent: vi.fn(),
|
||||
isReadOnlySibling: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock("~/models/playoff-match", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
findPlayoffMatchesByEventId: vi.fn(),
|
||||
deletePlayoffMatchesByEventId: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/participant-result", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
deleteParticipantResultsBySportsSeasonId: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
recalculateAffectedLeagues: vi.fn(),
|
||||
}));
|
||||
|
||||
const EVENT = { id: "event-1", sportsSeasonId: "season-1" };
|
||||
const params = { id: "season-1", eventId: "event-1" };
|
||||
|
||||
function clearRequest(confirm?: string): Request {
|
||||
const body = new FormData();
|
||||
body.set("intent", "clear-bracket");
|
||||
if (confirm !== undefined) body.set("confirm", confirm);
|
||||
return new Request("http://localhost/clear", { method: "POST", body });
|
||||
}
|
||||
|
||||
function match(isComplete: boolean) {
|
||||
return { id: `m-${Math.random()}`, isComplete };
|
||||
}
|
||||
|
||||
// The action's real signature carries React Router's generated types; the clear path
|
||||
// only reads request and params.
|
||||
const run = (request: Request) =>
|
||||
(action as unknown as (args: { request: Request; params: typeof params }) => Promise<{
|
||||
error?: string;
|
||||
success?: string;
|
||||
}>)({ request, params });
|
||||
|
||||
describe("clear-bracket", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getScoringEventById).mockResolvedValue(
|
||||
EVENT as unknown as Awaited<ReturnType<typeof getScoringEventById>>
|
||||
);
|
||||
vi.mocked(deletePlayoffMatchesByEventId).mockResolvedValue(undefined);
|
||||
vi.mocked(deleteParticipantResultsBySportsSeasonId).mockResolvedValue(undefined);
|
||||
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(
|
||||
undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes the matches", async () => {
|
||||
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
||||
match(false),
|
||||
match(false),
|
||||
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
|
||||
|
||||
const result = await run(clearRequest());
|
||||
|
||||
expect(result.success).toContain("2 match(es) removed");
|
||||
expect(deletePlayoffMatchesByEventId).toHaveBeenCalledWith("event-1");
|
||||
});
|
||||
|
||||
it("leaves placements alone — they belong to the whole season, not this event", async () => {
|
||||
// seasonParticipantResults is keyed by sports season, so deleting here would wipe
|
||||
// every other event's placements with nothing to rebuild them. Reprocess Bracket is
|
||||
// the tool that rebuilds them correctly.
|
||||
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
||||
match(true),
|
||||
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
|
||||
|
||||
const result = await run(clearRequest("true"));
|
||||
|
||||
expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled();
|
||||
expect(result.success).toContain("Reprocess Bracket");
|
||||
});
|
||||
|
||||
it("refuses to discard completed matches without confirmation", async () => {
|
||||
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
||||
match(true),
|
||||
match(false),
|
||||
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
|
||||
|
||||
const result = await run(clearRequest());
|
||||
|
||||
expect(result.error).toContain("1 completed match(es)");
|
||||
expect(deletePlayoffMatchesByEventId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards completed matches once confirmed", async () => {
|
||||
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue([
|
||||
match(true),
|
||||
] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>);
|
||||
|
||||
const result = await run(clearRequest("true"));
|
||||
|
||||
expect(result.success).toBeDefined();
|
||||
expect(deletePlayoffMatchesByEventId).toHaveBeenCalledWith("event-1");
|
||||
});
|
||||
|
||||
it("rejects an event with no bracket rather than reporting a no-op success", async () => {
|
||||
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue(
|
||||
[] as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>
|
||||
);
|
||||
|
||||
const result = await run(clearRequest("true"));
|
||||
|
||||
expect(result.error).toContain("no bracket to clear");
|
||||
expect(deletePlayoffMatchesByEventId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
/**
|
||||
* generate-bracket banks the floors a seeding guarantees before anyone plays (an AFL
|
||||
* top-4 seed cannot finish below the 5th-6th tier). Those floors only reach
|
||||
* teamStandings.totalPoints through a standings recalculation, so the action has to be
|
||||
* sure one ran — markEliminatedAndAnnounce runs one for its Discord announcement in some
|
||||
* cases but not others.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { generateBracketFromTemplate } from "~/models/playoff-match";
|
||||
import {
|
||||
findParticipantResultsBySportsSeasonId,
|
||||
setParticipantResult,
|
||||
} from "~/models/participant-result";
|
||||
import {
|
||||
applyBracketEntryFloors,
|
||||
recalculateAffectedLeagues,
|
||||
} from "~/models/scoring-calculator";
|
||||
import { getScoringEventById, updateScoringEvent } from "~/models/scoring-event";
|
||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
|
||||
|
||||
vi.mock("~/database/context", () => ({ database: vi.fn(() => ({})) }));
|
||||
vi.mock("~/models/scoring-event", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
getScoringEventById: vi.fn(),
|
||||
updateScoringEvent: vi.fn(),
|
||||
isReadOnlySibling: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock("~/models/playoff-match", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
generateBracketFromTemplate: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/participant-result", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
findParticipantResultsBySportsSeasonId: vi.fn(),
|
||||
setParticipantResult: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
applyBracketEntryFloors: vi.fn(),
|
||||
recalculateAffectedLeagues: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/season-participant", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
findParticipantsBySportsSeasonId: vi.fn(),
|
||||
}));
|
||||
|
||||
const params = { id: "season-1", eventId: "event-1" };
|
||||
|
||||
const EVENT = {
|
||||
id: "event-1",
|
||||
name: "AFL Finals",
|
||||
sportsSeasonId: "season-1",
|
||||
isQualifyingEvent: false,
|
||||
bracketTemplateId: "afl_10",
|
||||
};
|
||||
|
||||
/** afl_10 takes exactly 10 seeded participants. */
|
||||
const SEEDED = Array.from({ length: 10 }, (_, i) => `seed-${i + 1}`);
|
||||
|
||||
function generateRequest(): Request {
|
||||
const body = new FormData();
|
||||
body.set("intent", "generate-bracket");
|
||||
body.set("templateId", "afl_10");
|
||||
SEEDED.forEach((id, i) => body.set(`participant${i}`, id));
|
||||
return new Request("http://localhost/generate", { method: "POST", body });
|
||||
}
|
||||
|
||||
const run = (request: Request) =>
|
||||
(action as unknown as (args: { request: Request; params: typeof params }) => Promise<{
|
||||
error?: string;
|
||||
success?: string;
|
||||
}>)({ request, params });
|
||||
|
||||
/**
|
||||
* @param extras participants in the season beyond the 10 seeded into the bracket —
|
||||
* these are the ones generate-bracket marks eliminated.
|
||||
* @param withExistingResults ids that already carry a result row, so
|
||||
* markEliminatedAndAnnounce treats them as not newly eliminated.
|
||||
*/
|
||||
function setSeason(extras: string[], withExistingResults: string[] = []) {
|
||||
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue(
|
||||
[...SEEDED, ...extras].map((id) => ({ id })) as unknown as Awaited<
|
||||
ReturnType<typeof findParticipantsBySportsSeasonId>
|
||||
>
|
||||
);
|
||||
vi.mocked(findParticipantResultsBySportsSeasonId).mockResolvedValue(
|
||||
withExistingResults.map((participantId) => ({ participantId })) as unknown as Awaited<
|
||||
ReturnType<typeof findParticipantResultsBySportsSeasonId>
|
||||
>
|
||||
);
|
||||
}
|
||||
|
||||
describe("generate-bracket entry-floor standings recalculation", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getScoringEventById).mockResolvedValue(
|
||||
EVENT as unknown as Awaited<ReturnType<typeof getScoringEventById>>
|
||||
);
|
||||
vi.mocked(generateBracketFromTemplate).mockResolvedValue(
|
||||
undefined as unknown as Awaited<ReturnType<typeof generateBracketFromTemplate>>
|
||||
);
|
||||
vi.mocked(updateScoringEvent).mockResolvedValue(
|
||||
undefined as unknown as Awaited<ReturnType<typeof updateScoringEvent>>
|
||||
);
|
||||
vi.mocked(setParticipantResult).mockResolvedValue(
|
||||
undefined as unknown as Awaited<ReturnType<typeof setParticipantResult>>
|
||||
);
|
||||
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(
|
||||
undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>
|
||||
);
|
||||
// afl_10 seeds 1-4 into the Qualifying Finals, whose entry floor is the 5th-6th tier.
|
||||
vi.mocked(applyBracketEntryFloors).mockResolvedValue(4);
|
||||
});
|
||||
|
||||
it("recalculates when every eliminated team already had a result row", async () => {
|
||||
// The second run of a generation: the first wrote position 0 for the non-bracket
|
||||
// participants, so nobody is *newly* eliminated and the announcement is skipped.
|
||||
// The floors banked moments ago would never reach the standings.
|
||||
setSeason(["extra-1"], ["extra-1"]);
|
||||
|
||||
const result = await run(generateRequest());
|
||||
|
||||
expect(result.success).toBeDefined();
|
||||
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(1);
|
||||
expect(recalculateAffectedLeagues).toHaveBeenCalledWith(
|
||||
"season-1",
|
||||
expect.anything(),
|
||||
expect.objectContaining({ skipDiscord: true })
|
||||
);
|
||||
});
|
||||
|
||||
it("recalculates for a qualifying event, which never announces eliminations", async () => {
|
||||
vi.mocked(getScoringEventById).mockResolvedValue(
|
||||
{ ...EVENT, isQualifyingEvent: true } as unknown as Awaited<
|
||||
ReturnType<typeof getScoringEventById>
|
||||
>
|
||||
);
|
||||
setSeason(["extra-1"]);
|
||||
|
||||
await run(generateRequest());
|
||||
|
||||
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(1);
|
||||
expect(recalculateAffectedLeagues).toHaveBeenCalledWith(
|
||||
"season-1",
|
||||
expect.anything(),
|
||||
expect.objectContaining({ skipDiscord: true })
|
||||
);
|
||||
});
|
||||
|
||||
it("recalculates when the bracket field is the whole season", async () => {
|
||||
setSeason([]);
|
||||
|
||||
await run(generateRequest());
|
||||
|
||||
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("recalculates when the elimination announcement threw", async () => {
|
||||
// The announcement is best-effort and its failure is swallowed — but a failed recalc
|
||||
// is exactly when the floors still need one.
|
||||
setSeason(["extra-1"]);
|
||||
vi.mocked(recalculateAffectedLeagues)
|
||||
.mockRejectedValueOnce(new Error("discord down"))
|
||||
.mockResolvedValue(undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>);
|
||||
|
||||
const result = await run(generateRequest());
|
||||
|
||||
expect(result.success).toBeDefined();
|
||||
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(2);
|
||||
expect(recalculateAffectedLeagues).toHaveBeenLastCalledWith(
|
||||
"season-1",
|
||||
expect.anything(),
|
||||
expect.objectContaining({ skipDiscord: true })
|
||||
);
|
||||
});
|
||||
|
||||
it("does not recalculate twice when the announcement already did", async () => {
|
||||
setSeason(["extra-1"]);
|
||||
|
||||
await run(generateRequest());
|
||||
|
||||
expect(recalculateAffectedLeagues).toHaveBeenCalledTimes(1);
|
||||
// The announcing call, not the floor fallback.
|
||||
expect(recalculateAffectedLeagues).toHaveBeenCalledWith(
|
||||
"season-1",
|
||||
expect.anything(),
|
||||
expect.objectContaining({ eliminatedParticipantIds: ["extra-1"] })
|
||||
);
|
||||
});
|
||||
|
||||
it("does not recalculate at all when no floors were banked", async () => {
|
||||
// A template that guarantees nothing at seeding: no floors, nobody to eliminate,
|
||||
// so there is nothing for a recalculation to pick up.
|
||||
vi.mocked(applyBracketEntryFloors).mockResolvedValue(0);
|
||||
setSeason([]);
|
||||
|
||||
await run(generateRequest());
|
||||
|
||||
expect(recalculateAffectedLeagues).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { newlyDecidedLosers } from "../admin.sports-seasons.$id.events.$eventId.bracket.helpers";
|
||||
|
||||
describe("newlyDecidedLosers", () => {
|
||||
it("returns losers of matches that were not already complete", () => {
|
||||
expect(
|
||||
newlyDecidedLosers([
|
||||
{ wasComplete: false, loserId: "sp-1" },
|
||||
{ wasComplete: false, loserId: "sp-2" },
|
||||
])
|
||||
).toEqual(["sp-1", "sp-2"]);
|
||||
});
|
||||
|
||||
it("drops re-scores of already-complete matches (no re-announce)", () => {
|
||||
// sp-1's match just finished; sp-2's match was already complete before this
|
||||
// action — only sp-1 is a NEW knockout.
|
||||
expect(
|
||||
newlyDecidedLosers([
|
||||
{ wasComplete: false, loserId: "sp-1" },
|
||||
{ wasComplete: true, loserId: "sp-2" },
|
||||
])
|
||||
).toEqual(["sp-1"]);
|
||||
});
|
||||
|
||||
it("skips entries with an unknown loser", () => {
|
||||
expect(
|
||||
newlyDecidedLosers([
|
||||
{ wasComplete: false, loserId: null },
|
||||
{ wasComplete: false, loserId: "sp-3" },
|
||||
])
|
||||
).toEqual(["sp-3"]);
|
||||
});
|
||||
|
||||
it("returns an empty array when nothing was newly decided", () => {
|
||||
expect(
|
||||
newlyDecidedLosers([
|
||||
{ wasComplete: true, loserId: "sp-1" },
|
||||
{ wasComplete: true, loserId: "sp-2" },
|
||||
])
|
||||
).toEqual([]);
|
||||
expect(newlyDecidedLosers([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
/**
|
||||
* reprocess-bracket rebuilds a bracket's placements from scratch. What it wipes first
|
||||
* decides whether the clear-bracket → regenerate → reprocess repair path actually works,
|
||||
* and whether it takes the rest of the season's placements down with it.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { findPlayoffMatchesByEventId } from "~/models/playoff-match";
|
||||
import {
|
||||
deleteParticipantResultsBySportsSeasonId,
|
||||
deleteParticipantResultsForParticipants,
|
||||
setParticipantResult,
|
||||
} from "~/models/participant-result";
|
||||
import {
|
||||
applyBracketEntryFloors,
|
||||
processMatchResult,
|
||||
processQualifyingBracketEvent,
|
||||
recalculateAffectedLeagues,
|
||||
} from "~/models/scoring-calculator";
|
||||
import { getScoringEventById } from "~/models/scoring-event";
|
||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||
import { findSportsSeasonById } from "~/models/sports-season";
|
||||
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
|
||||
|
||||
vi.mock("~/database/context", () => ({ database: vi.fn(() => ({})) }));
|
||||
vi.mock("~/models/scoring-event", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
getScoringEventById: vi.fn(),
|
||||
updateScoringEvent: vi.fn(),
|
||||
isReadOnlySibling: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock("~/models/playoff-match", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
findPlayoffMatchesByEventId: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/participant-result", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
deleteParticipantResultsBySportsSeasonId: vi.fn(),
|
||||
deleteParticipantResultsForParticipants: vi.fn(),
|
||||
setParticipantResult: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
applyBracketEntryFloors: vi.fn(),
|
||||
processMatchResult: vi.fn(),
|
||||
recalculateAffectedLeagues: vi.fn(),
|
||||
processQualifyingBracketEvent: vi.fn(),
|
||||
finalizeQualifyingPoints: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/season-participant", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
findParticipantsBySportsSeasonId: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/sports-season", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
findSportsSeasonById: vi.fn(),
|
||||
}));
|
||||
|
||||
const params = { id: "season-1", eventId: "event-1" };
|
||||
|
||||
const EVENT = {
|
||||
id: "event-1",
|
||||
name: "AFL Finals",
|
||||
sportsSeasonId: "season-1",
|
||||
isQualifyingEvent: false,
|
||||
isPrimary: false,
|
||||
tournamentId: null,
|
||||
bracketTemplateId: "afl_10",
|
||||
};
|
||||
|
||||
function reprocessRequest(): Request {
|
||||
const body = new FormData();
|
||||
body.set("intent", "reprocess-bracket");
|
||||
return new Request("http://localhost/reprocess", { method: "POST", body });
|
||||
}
|
||||
|
||||
/** A seeded, unplayed bracket slot. */
|
||||
function slot(matchNumber: number, participant1Id: string, participant2Id: string) {
|
||||
return {
|
||||
id: `m-${matchNumber}`,
|
||||
round: "Qualifying Finals",
|
||||
matchNumber,
|
||||
participant1Id,
|
||||
participant2Id,
|
||||
winnerId: null,
|
||||
loserId: null,
|
||||
isComplete: false,
|
||||
isScoring: true,
|
||||
};
|
||||
}
|
||||
|
||||
const run = (request: Request) =>
|
||||
(action as unknown as (args: { request: Request; params: typeof params }) => Promise<{
|
||||
error?: string;
|
||||
success?: string;
|
||||
}>)({ request, params });
|
||||
|
||||
function setEvent(overrides: Partial<typeof EVENT> = {}) {
|
||||
vi.mocked(getScoringEventById).mockResolvedValue(
|
||||
{ ...EVENT, ...overrides } as unknown as Awaited<ReturnType<typeof getScoringEventById>>
|
||||
);
|
||||
}
|
||||
|
||||
function setMatches(matches: ReturnType<typeof slot>[]) {
|
||||
vi.mocked(findPlayoffMatchesByEventId).mockResolvedValue(
|
||||
matches as unknown as Awaited<ReturnType<typeof findPlayoffMatchesByEventId>>
|
||||
);
|
||||
}
|
||||
|
||||
describe("reprocess-bracket", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setEvent();
|
||||
vi.mocked(applyBracketEntryFloors).mockResolvedValue(4);
|
||||
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue(
|
||||
[] as unknown as Awaited<ReturnType<typeof findParticipantsBySportsSeasonId>>
|
||||
);
|
||||
vi.mocked(setParticipantResult).mockResolvedValue(
|
||||
undefined as unknown as Awaited<ReturnType<typeof setParticipantResult>>
|
||||
);
|
||||
vi.mocked(recalculateAffectedLeagues).mockResolvedValue(
|
||||
undefined as unknown as Awaited<ReturnType<typeof recalculateAffectedLeagues>>
|
||||
);
|
||||
vi.mocked(processMatchResult).mockResolvedValue(
|
||||
undefined as unknown as Awaited<ReturnType<typeof processMatchResult>>
|
||||
);
|
||||
vi.mocked(processQualifyingBracketEvent).mockResolvedValue(
|
||||
undefined as unknown as Awaited<ReturnType<typeof processQualifyingBracketEvent>>
|
||||
);
|
||||
vi.mocked(findSportsSeasonById).mockResolvedValue(
|
||||
{ qualifyingPointsFinalized: false } as unknown as Awaited<
|
||||
ReturnType<typeof findSportsSeasonById>
|
||||
>
|
||||
);
|
||||
});
|
||||
|
||||
it("clears placements even when no match has been played", async () => {
|
||||
// The clear-bracket → regenerate → reprocess repair path lands here: the freshly
|
||||
// re-seeded bracket has nothing completed, yet the discarded bracket's finalized
|
||||
// placements are exactly what has to go. Skipping the wipe leaves them permanently,
|
||||
// because upsertParticipantResult refuses to un-finalize a result.
|
||||
setMatches([slot(1, "p1", "p2"), slot(2, "p3", "p4")]);
|
||||
|
||||
const result = await run(reprocessRequest());
|
||||
|
||||
expect(result.success).toBeDefined();
|
||||
expect(deleteParticipantResultsForParticipants).toHaveBeenCalledTimes(1);
|
||||
const [sportsSeasonId, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
|
||||
expect(sportsSeasonId).toBe("season-1");
|
||||
expect([...ids].toSorted()).toEqual(["p1", "p2", "p3", "p4"]);
|
||||
});
|
||||
|
||||
it("scopes the wipe to this bracket, never the whole season", async () => {
|
||||
// A season-wide delete would take every other event's placements with it, with only
|
||||
// this bracket's replay able to rebuild them.
|
||||
setMatches([slot(1, "p1", "p2")]);
|
||||
|
||||
await run(reprocessRequest());
|
||||
|
||||
expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled();
|
||||
const [, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
|
||||
expect(ids).not.toContain("p3");
|
||||
});
|
||||
|
||||
it("passes each participant once when a team appears in more than one slot", async () => {
|
||||
setMatches([slot(1, "p1", "p2"), slot(2, "p1", "p3")]);
|
||||
|
||||
await run(reprocessRequest());
|
||||
|
||||
const [, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
|
||||
expect(ids).toHaveLength(3);
|
||||
expect([...ids].toSorted()).toEqual(["p1", "p2", "p3"]);
|
||||
});
|
||||
|
||||
it("skips empty slots rather than passing nulls through", async () => {
|
||||
setMatches([
|
||||
{ ...slot(1, "p1", "p2"), participant2Id: null as unknown as string },
|
||||
]);
|
||||
|
||||
await run(reprocessRequest());
|
||||
|
||||
const [, ids] = vi.mocked(deleteParticipantResultsForParticipants).mock.calls[0];
|
||||
expect(ids).toEqual(["p1"]);
|
||||
});
|
||||
|
||||
it("still takes the season-wide delete for a qualifying event", async () => {
|
||||
// Qualifying seasons have no legitimate per-major fantasy placements — those come
|
||||
// from finalizeQualifyingPoints across all majors — so that path wipes the season
|
||||
// on purpose and rebuilds QP from the bracket.
|
||||
setEvent({ isQualifyingEvent: true });
|
||||
setMatches([slot(1, "p1", "p2")]);
|
||||
|
||||
await run(reprocessRequest());
|
||||
|
||||
expect(deleteParticipantResultsBySportsSeasonId).toHaveBeenCalledWith("season-1", {});
|
||||
expect(deleteParticipantResultsForParticipants).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an event with no bracket rather than wiping anything", async () => {
|
||||
setMatches([]);
|
||||
|
||||
const result = await run(reprocessRequest());
|
||||
|
||||
expect(result.error).toContain("No bracket to reprocess");
|
||||
expect(deleteParticipantResultsForParticipants).not.toHaveBeenCalled();
|
||||
expect(deleteParticipantResultsBySportsSeasonId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
/**
|
||||
* The Fix Semi-Final Pairings admin action.
|
||||
*
|
||||
* Elimination Final n feeds Semi-Final n, but brackets advanced before that was fixed
|
||||
* crossed the two winners, and nothing re-runs advancement — a completed match cannot be
|
||||
* re-submitted from the UI.
|
||||
*
|
||||
* It moves qualifier slots only — no scoring runs, so nothing reaches Discord.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { reseedAflSemiFinals } from "~/models/playoff-match";
|
||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||
import { getScoringEventById } from "~/models/scoring-event";
|
||||
import { processMatchResult, recalculateAffectedLeagues } from "~/models/scoring-calculator";
|
||||
import { sendDiscordWebhook } from "~/services/discord";
|
||||
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
|
||||
|
||||
vi.mock("~/database/context", () => ({ database: vi.fn(() => ({})) }));
|
||||
vi.mock("~/models/scoring-event", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
getScoringEventById: vi.fn(),
|
||||
isReadOnlySibling: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock("~/models/playoff-match", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
reseedAflSemiFinals: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/season-participant", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
findParticipantsBySportsSeasonId: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
processMatchResult: vi.fn(),
|
||||
recalculateAffectedLeagues: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/services/discord", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
sendDiscordWebhook: vi.fn(),
|
||||
}));
|
||||
|
||||
const params = { id: "season-1", eventId: "event-1" };
|
||||
|
||||
const EVENT = {
|
||||
id: "event-1",
|
||||
name: "AFL Finals",
|
||||
sportsSeasonId: "season-1",
|
||||
isQualifyingEvent: false,
|
||||
bracketTemplateId: "afl_10",
|
||||
};
|
||||
|
||||
function request() {
|
||||
const body = new FormData();
|
||||
body.set("intent", "reseed-afl-semifinals");
|
||||
return new Request("http://localhost/bracket", { method: "POST", body });
|
||||
}
|
||||
|
||||
const run = () => action({ request: request(), params } as never);
|
||||
|
||||
describe("reseed-afl-semifinals", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getScoringEventById).mockResolvedValue(EVENT as never);
|
||||
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue([
|
||||
{ id: "geelong", name: "Geelong Cats" },
|
||||
{ id: "adelaide", name: "Adelaide Crows" },
|
||||
] as never);
|
||||
});
|
||||
|
||||
it("names the teams that moved", async () => {
|
||||
vi.mocked(reseedAflSemiFinals).mockResolvedValue({
|
||||
vacated: [1, 2],
|
||||
filled: [
|
||||
{ matchNumber: 2, participantId: "adelaide" },
|
||||
{ matchNumber: 1, participantId: "geelong" },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(reseedAflSemiFinals).toHaveBeenCalledWith("event-1");
|
||||
expect(result).toEqual({
|
||||
success:
|
||||
"Re-seeded the Semi-Finals: match 1 now hosts Geelong Cats, " +
|
||||
"match 2 now hosts Adelaide Crows.",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a slot that was emptied without being refilled", async () => {
|
||||
// Un-recording an Elimination Final result takes its winner back out of the semi.
|
||||
vi.mocked(reseedAflSemiFinals).mockResolvedValue({
|
||||
vacated: [1, 2],
|
||||
filled: [{ matchNumber: 2, participantId: "adelaide" }],
|
||||
});
|
||||
|
||||
expect(await run()).toEqual({
|
||||
success:
|
||||
"Re-seeded the Semi-Finals: match 1 is back to TBD, " +
|
||||
"match 2 now hosts Adelaide Crows.",
|
||||
});
|
||||
});
|
||||
|
||||
it("says so when the pairings are already right", async () => {
|
||||
vi.mocked(reseedAflSemiFinals).mockResolvedValue({ vacated: [], filled: [] });
|
||||
|
||||
expect(await run()).toEqual({
|
||||
success: "Semi-Finals already match the Elimination Finals results — nothing to re-seed.",
|
||||
});
|
||||
});
|
||||
|
||||
it("scores nothing and announces nothing", async () => {
|
||||
vi.mocked(reseedAflSemiFinals).mockResolvedValue({
|
||||
vacated: [1, 2],
|
||||
filled: [{ matchNumber: 1, participantId: "geelong" }],
|
||||
});
|
||||
|
||||
await run();
|
||||
|
||||
expect(processMatchResult).not.toHaveBeenCalled();
|
||||
expect(recalculateAffectedLeagues).not.toHaveBeenCalled();
|
||||
expect(sendDiscordWebhook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a bracket that is not an AFL finals bracket", async () => {
|
||||
vi.mocked(getScoringEventById).mockResolvedValue({
|
||||
...EVENT,
|
||||
bracketTemplateId: "nfl_14",
|
||||
} as never);
|
||||
|
||||
expect(await run()).toEqual({
|
||||
error: "This action only applies to AFL finals brackets",
|
||||
});
|
||||
expect(reseedAflSemiFinals).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a refusal to re-seed a game that has been played", async () => {
|
||||
vi.mocked(reseedAflSemiFinals).mockRejectedValue(
|
||||
new Error("Semi-Finals match 1 already has a recorded result")
|
||||
);
|
||||
|
||||
expect(await run()).toEqual({
|
||||
error: "Semi-Finals match 1 already has a recorded result",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
/**
|
||||
* The Re-seed Wildcard Winners admin action.
|
||||
*
|
||||
* Advancement pairs the Wildcard winners with 5th and 6th by ladder position on every
|
||||
* result, so this action exists for brackets advanced before that rule: their winners sit
|
||||
* in the wrong Elimination Finals and nothing re-runs advancement, because a completed
|
||||
* match cannot be re-submitted from the UI.
|
||||
*
|
||||
* It moves qualifier slots only — no scoring runs, so nothing reaches Discord.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { reseedAflEliminationFinals } from "~/models/playoff-match";
|
||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||
import { getScoringEventById } from "~/models/scoring-event";
|
||||
import { processMatchResult, recalculateAffectedLeagues } from "~/models/scoring-calculator";
|
||||
import { sendDiscordWebhook } from "~/services/discord";
|
||||
import { action } from "../admin.sports-seasons.$id.events.$eventId.bracket.server";
|
||||
|
||||
vi.mock("~/database/context", () => ({ database: vi.fn(() => ({})) }));
|
||||
vi.mock("~/models/scoring-event", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
getScoringEventById: vi.fn(),
|
||||
isReadOnlySibling: vi.fn(() => false),
|
||||
}));
|
||||
vi.mock("~/models/playoff-match", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
reseedAflEliminationFinals: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/season-participant", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
findParticipantsBySportsSeasonId: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/scoring-calculator", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
processMatchResult: vi.fn(),
|
||||
recalculateAffectedLeagues: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/services/discord", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
sendDiscordWebhook: vi.fn(),
|
||||
}));
|
||||
|
||||
const params = { id: "season-1", eventId: "event-1" };
|
||||
|
||||
const EVENT = {
|
||||
id: "event-1",
|
||||
name: "AFL Finals",
|
||||
sportsSeasonId: "season-1",
|
||||
isQualifyingEvent: false,
|
||||
bracketTemplateId: "afl_10",
|
||||
};
|
||||
|
||||
function request() {
|
||||
const body = new FormData();
|
||||
body.set("intent", "reseed-afl-wildcard");
|
||||
return new Request("http://localhost/bracket", { method: "POST", body });
|
||||
}
|
||||
|
||||
const run = () => action({ request: request(), params } as never);
|
||||
|
||||
describe("reseed-afl-wildcard", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getScoringEventById).mockResolvedValue(EVENT as never);
|
||||
vi.mocked(findParticipantsBySportsSeasonId).mockResolvedValue([
|
||||
{ id: "carlton", name: "Carlton Blues" },
|
||||
{ id: "bulldogs", name: "Western Bulldogs" },
|
||||
] as never);
|
||||
});
|
||||
|
||||
it("names the teams that moved", async () => {
|
||||
vi.mocked(reseedAflEliminationFinals).mockResolvedValue({
|
||||
vacated: [1, 2],
|
||||
filled: [
|
||||
{ matchNumber: 2, participantId: "bulldogs" },
|
||||
{ matchNumber: 1, participantId: "carlton" },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(reseedAflEliminationFinals).toHaveBeenCalledWith("event-1");
|
||||
expect(result).toEqual({
|
||||
success:
|
||||
"Re-seeded the Elimination Finals: match 1 now hosts Carlton Blues, " +
|
||||
"match 2 now hosts Western Bulldogs.",
|
||||
});
|
||||
});
|
||||
|
||||
it("says so when the pairings are already right", async () => {
|
||||
vi.mocked(reseedAflEliminationFinals).mockResolvedValue({ vacated: [], filled: [] });
|
||||
|
||||
expect(await run()).toEqual({
|
||||
success: "Elimination Finals already match the Wildcard results — nothing to re-seed.",
|
||||
});
|
||||
});
|
||||
|
||||
it("scores nothing and announces nothing", async () => {
|
||||
vi.mocked(reseedAflEliminationFinals).mockResolvedValue({
|
||||
vacated: [1, 2],
|
||||
filled: [{ matchNumber: 1, participantId: "carlton" }],
|
||||
});
|
||||
|
||||
await run();
|
||||
|
||||
expect(processMatchResult).not.toHaveBeenCalled();
|
||||
expect(recalculateAffectedLeagues).not.toHaveBeenCalled();
|
||||
expect(sendDiscordWebhook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a bracket that is not an AFL finals bracket", async () => {
|
||||
vi.mocked(getScoringEventById).mockResolvedValue({
|
||||
...EVENT,
|
||||
bracketTemplateId: "nfl_14",
|
||||
} as never);
|
||||
|
||||
expect(await run()).toEqual({
|
||||
error: "This action only applies to AFL finals brackets",
|
||||
});
|
||||
expect(reseedAflEliminationFinals).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces a refusal to re-seed a game that has been played", async () => {
|
||||
vi.mocked(reseedAflEliminationFinals).mockRejectedValue(
|
||||
new Error("Elimination Finals match 1 already has a recorded result")
|
||||
);
|
||||
|
||||
expect(await run()).toEqual({
|
||||
error: "Elimination Finals match 1 already has a recorded result",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
vi.mock("~/lib/auth.server", () => ({
|
||||
auth: { api: { getSession: vi.fn() } },
|
||||
}));
|
||||
vi.mock("~/lib/cloudinary.server", () => ({ deleteCloudinaryImageByUrl: vi.fn() }));
|
||||
vi.mock("~/lib/email.server", () => ({
|
||||
sendEmail: vi.fn(),
|
||||
wrapInEmailTemplate: vi.fn(),
|
||||
emailParagraph: vi.fn(),
|
||||
escapeHtml: vi.fn(),
|
||||
}));
|
||||
vi.mock("~/models/user", () => ({
|
||||
findUserById: vi.fn(),
|
||||
findUserByUsername: vi.fn(),
|
||||
isUserInActiveDraft: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
anonymizeUserAccount: vi.fn(),
|
||||
USERNAME_RE: /^[a-zA-Z0-9_-]{3,30}$/,
|
||||
}));
|
||||
vi.mock("~/models/account", () => ({ findLinkedAccountsByUserId: vi.fn() }));
|
||||
vi.mock("~/models/team", () => ({ findTeamsByOwnerId: vi.fn(), removeTeamOwner: vi.fn() }));
|
||||
vi.mock("~/models/commissioner", () => ({ removeAllCommissionersByUserId: vi.fn() }));
|
||||
|
||||
import { loader, shouldRevalidate } from "../settings";
|
||||
import { auth } from "~/lib/auth.server";
|
||||
|
||||
type RevalidateArgs = Parameters<typeof shouldRevalidate>[0];
|
||||
|
||||
function revalidateArgs(overrides: Partial<RevalidateArgs>): RevalidateArgs {
|
||||
return {
|
||||
currentParams: {},
|
||||
nextParams: {},
|
||||
formMethod: undefined,
|
||||
defaultShouldRevalidate: true,
|
||||
...overrides,
|
||||
} as unknown as RevalidateArgs;
|
||||
}
|
||||
|
||||
type LoaderArgs = Parameters<typeof loader>[0];
|
||||
|
||||
function loaderArgs(section?: string): LoaderArgs {
|
||||
const path = section ? `/settings/${section}` : "/settings";
|
||||
return {
|
||||
params: { section },
|
||||
request: new Request(`http://localhost${path}`),
|
||||
context: {},
|
||||
} as unknown as LoaderArgs;
|
||||
}
|
||||
|
||||
describe("/settings loader section handling", () => {
|
||||
it("redirects an unknown section back to /settings", async () => {
|
||||
const result = (await loader(loaderArgs("bogus"))) as Response;
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result.status).toBe(302);
|
||||
expect(result.headers.get("Location")).toBe("/settings");
|
||||
// The bad section short-circuits before we ever touch the session.
|
||||
expect(auth.api.getSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not redirect a valid section before auth runs", async () => {
|
||||
vi.mocked(auth.api.getSession).mockResolvedValue(null as never);
|
||||
const result = (await loader(loaderArgs("account"))) as Response;
|
||||
// Falls through to the auth check, which redirects to login (not /settings).
|
||||
expect(result.headers.get("Location")).toBe("/login?redirectTo=/settings");
|
||||
expect(auth.api.getSession).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("/settings shouldRevalidate", () => {
|
||||
it("skips revalidation when only the section param changes", () => {
|
||||
expect(
|
||||
shouldRevalidate(
|
||||
revalidateArgs({ currentParams: { section: "profile" }, nextParams: { section: "account" } })
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("revalidates after a mutation regardless of section", () => {
|
||||
expect(
|
||||
shouldRevalidate(
|
||||
revalidateArgs({
|
||||
currentParams: { section: "profile" },
|
||||
nextParams: { section: "account" },
|
||||
formMethod: "POST",
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("defers to the default when the section is unchanged", () => {
|
||||
expect(
|
||||
shouldRevalidate(
|
||||
revalidateArgs({
|
||||
currentParams: { section: "account" },
|
||||
nextParams: { section: "account" },
|
||||
defaultShouldRevalidate: true,
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
import { Link } from "react-router";
|
||||
import type { Route } from "./+types/admin.draft-schedule";
|
||||
|
||||
import { findAllSports } from "~/models/sport";
|
||||
import { findDraftScheduleForHorizon } from "~/models/sports-season";
|
||||
import {
|
||||
DraftScheduleGantt,
|
||||
type GanttSport,
|
||||
} from "~/components/admin/DraftScheduleGantt";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { AlertTriangle, ArrowRight } from "lucide-react";
|
||||
|
||||
const ALLOWED_MONTHS = [6, 12] as const;
|
||||
|
||||
export function meta(): Route.MetaDescriptors {
|
||||
return [{ title: "Draft Schedule - Brackt" }];
|
||||
}
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const url = new URL(request.url);
|
||||
const monthsParam = Number(url.searchParams.get("months"));
|
||||
const months = ALLOWED_MONTHS.includes(monthsParam as (typeof ALLOWED_MONTHS)[number])
|
||||
? monthsParam
|
||||
: 6;
|
||||
|
||||
const [allSports, windows] = await Promise.all([
|
||||
findAllSports(),
|
||||
findDraftScheduleForHorizon(months),
|
||||
]);
|
||||
|
||||
const windowsBySport = new Map<string, typeof windows>();
|
||||
for (const w of windows) {
|
||||
const list = windowsBySport.get(w.sport.id) ?? [];
|
||||
list.push(w);
|
||||
windowsBySport.set(w.sport.id, list);
|
||||
}
|
||||
|
||||
const sports: GanttSport[] = allSports.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
iconUrl: s.iconUrl,
|
||||
windows: windowsBySport.get(s.id) ?? [],
|
||||
}));
|
||||
|
||||
const sportsWithoutCoverage = sports.filter((s) => s.windows.length === 0);
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
return { sports, sportsWithoutCoverage, today, months };
|
||||
}
|
||||
|
||||
export default function AdminDraftSchedule({ loaderData }: Route.ComponentProps) {
|
||||
const { sports, sportsWithoutCoverage, today, months } = loaderData;
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-8">
|
||||
<div className="mb-8 flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Draft Schedule</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
See when each sport is scheduled to be drafted and spot gaps in coverage.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-md border p-1">
|
||||
{ALLOWED_MONTHS.map((m) => (
|
||||
<Button
|
||||
key={m}
|
||||
variant={m === months ? "default" : "ghost"}
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<Link to={`?months=${m}`}>{m} months</Link>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sportsWithoutCoverage.length > 0 && (
|
||||
<Card className="mb-6 border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-amber-800 dark:text-amber-300">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
Sports without a draft window
|
||||
</CardTitle>
|
||||
<CardDescription className="text-amber-700 dark:text-amber-400/80">
|
||||
{sportsWithoutCoverage.length} sport
|
||||
{sportsWithoutCoverage.length !== 1 ? "s" : ""}{" "}
|
||||
{sportsWithoutCoverage.length !== 1 ? "have" : "has"} no draft window
|
||||
open or upcoming in the next {months} months.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{sportsWithoutCoverage.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-amber-200 bg-background/60 px-3 py-2 dark:border-amber-900"
|
||||
>
|
||||
<span className="text-sm font-medium">{s.name}</span>
|
||||
<Button variant="outline" size="sm" asChild className="h-7 text-xs">
|
||||
<Link to="/admin/sports-seasons/new">
|
||||
Schedule a season
|
||||
<ArrowRight className="ml-1 h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<DraftScheduleGantt sports={sports} today={today} months={months} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -77,12 +77,6 @@ export async function action({ request }: Route.ActionArgs): Promise<ActionData>
|
|||
};
|
||||
}
|
||||
|
||||
function futuresBlendLabel(oddsWeight: number): string {
|
||||
if (oddsWeight >= 1) return "overrides Elo";
|
||||
if (oddsWeight <= 0) return "Elo only";
|
||||
return `${Math.round(oddsWeight * 100)}% blend`;
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
if (status === "ready") return <Badge className="gap-1"><CheckCircle2 className="h-3 w-3" /> Ready</Badge>;
|
||||
return <Badge variant="secondary" className="gap-1"><XCircle className="h-3 w-3" /> Needs setup</Badge>;
|
||||
|
|
@ -251,14 +245,7 @@ export default function AdminSimulators({ loaderData }: Route.ComponentProps) {
|
|||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
<div>{sim.participantInputCount}/{sim.participantCount}</div>
|
||||
{sim.supportsFuturesOdds && (
|
||||
<Badge variant={sim.oddsWeight > 0 ? "default" : "outline"} className="mt-1 text-xs font-normal">
|
||||
{sim.oddsParticipantCount > 0
|
||||
? `Futures: ${sim.oddsParticipantCount} · ${futuresBlendLabel(sim.oddsWeight)}`
|
||||
: "No futures odds"}
|
||||
</Badge>
|
||||
)}
|
||||
{sim.participantInputCount}/{sim.participantCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{sim.lastSimulatedDate ?? "Never"}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import {
|
|||
projectedWinsToElo,
|
||||
} from '~/services/probability-engine';
|
||||
import { runSportsSeasonSimulation } from '~/services/simulations/runner';
|
||||
import { getParticipantSimulatorInputs, getSportsSeasonSimulatorConfig } from '~/models/simulator';
|
||||
import { getSportsSeasonSimulatorConfig } from '~/models/simulator';
|
||||
|
||||
// Simulator types that use worldRanking in addition to sourceElo
|
||||
const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points', 'college_hockey_bracket']);
|
||||
|
|
@ -80,38 +80,13 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
|
||||
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
|
||||
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
||||
const simulatorInputs = await getParticipantSimulatorInputs(sportsSeasonId);
|
||||
|
||||
// The projection a participant was actually saved with. Read it back verbatim:
|
||||
// deriving the field from the stored Elo instead (as this page used to) shows the
|
||||
// admin a different number than they typed, because wins → Elo rounds to an
|
||||
// integer Elo and a simulation run then re-resolves that Elo through the input
|
||||
// policy (clamping, and blending in futures odds when a season has them).
|
||||
const projectionsByParticipant = new Map(
|
||||
simulatorInputs.map((input) => [
|
||||
input.participantId,
|
||||
{ projectedWins: input.projectedWins, projectedTablePoints: input.projectedTablePoints },
|
||||
])
|
||||
);
|
||||
|
||||
const existingData: Record<
|
||||
string,
|
||||
{ elo: number | null; ranking: number | null; projectedWins: number | null; projectedTablePoints: number | null }
|
||||
> = {};
|
||||
for (const participant of participants) {
|
||||
const projection = projectionsByParticipant.get(participant.id);
|
||||
existingData[participant.id] = {
|
||||
elo: null,
|
||||
ranking: null,
|
||||
projectedWins: projection?.projectedWins ?? null,
|
||||
projectedTablePoints: projection?.projectedTablePoints ?? null,
|
||||
};
|
||||
}
|
||||
const existingData: Record<string, { elo: number | null; ranking: number | null }> = {};
|
||||
for (const ev of existingEVs) {
|
||||
const existing = existingData[ev.participantId];
|
||||
if (!existing) continue;
|
||||
existing.elo = ev.sourceElo ?? null;
|
||||
existing.ranking = ev.worldRanking ?? null;
|
||||
existingData[ev.participantId] = {
|
||||
elo: ev.sourceElo ?? null,
|
||||
ranking: ev.worldRanking ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? '');
|
||||
|
|
@ -277,16 +252,7 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
if (simulatorConfig) {
|
||||
participants.forEach(p => {
|
||||
const d = existingData[p.id];
|
||||
// A stored projection is shown exactly as it was entered. Only fall back to
|
||||
// deriving it from the Elo when this season has no projection saved (a
|
||||
// season that has only ever had Elos entered still gets a useful starting
|
||||
// point) — that derived value is lossy and must never overwrite a real one.
|
||||
const stored = simulatorConfig.projectionInput === 'tablePoints'
|
||||
? d?.projectedTablePoints
|
||||
: d?.projectedWins;
|
||||
if (stored !== null && stored !== undefined) {
|
||||
initial[p.id] = stored.toString();
|
||||
} else if (d?.elo !== null && d?.elo !== undefined) {
|
||||
if (d?.elo !== null && d?.elo !== undefined) {
|
||||
initial[p.id] = (simulatorConfig.projectionInput === 'tablePoints'
|
||||
? eloToProjectedTablePoints(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||||
: eloToProjectedWins(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||||
|
|
@ -299,8 +265,8 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
|
||||
const [bulkText, setBulkText] = useState('');
|
||||
const [parseResults, setParseResults] = useState<{
|
||||
matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; projection: number | null; inputName: string }>;
|
||||
unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null; projection: number | null }>;
|
||||
matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }>;
|
||||
unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null }>;
|
||||
} | null>(null);
|
||||
|
||||
function findParticipantMatch(inputName: string) {
|
||||
|
|
@ -325,8 +291,8 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
|
||||
function parseBulkText() {
|
||||
const lines = bulkText.split('\n');
|
||||
const matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; projection: number | null; inputName: string }> = [];
|
||||
const unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null; projection: number | null }> = [];
|
||||
const matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }> = [];
|
||||
const unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null }> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const line of lines) {
|
||||
|
|
@ -349,9 +315,9 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
const participant = findParticipantMatch(inputName);
|
||||
if (participant && !seen.has(participant.id)) {
|
||||
seen.add(participant.id);
|
||||
matched.push({ participantId: participant.id, name: participant.name, elo, ranking: null, projection: projectedWins, inputName });
|
||||
matched.push({ participantId: participant.id, name: participant.name, elo, ranking: null, inputName });
|
||||
} else if (!participant) {
|
||||
unmatched.push({ inputName, elo, ranking: null, projection: projectedWins });
|
||||
unmatched.push({ inputName, elo, ranking: null });
|
||||
}
|
||||
} else {
|
||||
const match = usesRanking
|
||||
|
|
@ -376,9 +342,9 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
const participant = findParticipantMatch(inputName);
|
||||
if (participant && !seen.has(participant.id)) {
|
||||
seen.add(participant.id);
|
||||
matched.push({ participantId: participant.id, name: participant.name, elo, ranking, projection: null, inputName });
|
||||
matched.push({ participantId: participant.id, name: participant.name, elo, ranking, inputName });
|
||||
} else if (!participant) {
|
||||
unmatched.push({ inputName, elo, ranking, projection: null });
|
||||
unmatched.push({ inputName, elo, ranking });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -394,11 +360,11 @@ export default function AdminSportsSeasonEloRatings() {
|
|||
for (const m of parseResults.matched) {
|
||||
if (m.elo !== null) newElos[m.participantId] = m.elo.toString();
|
||||
if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString();
|
||||
// The pasted number goes in as typed. Round-tripping it through the derived
|
||||
// Elo (as this used to) drifts it by up to half an Elo point — a pasted 95
|
||||
// came back as 95.1 before anything was even saved.
|
||||
if (inputMode === 'projectedWins' && m.projection !== null) {
|
||||
newWins[m.participantId] = m.projection.toString();
|
||||
if (inputMode === 'projectedWins' && simulatorConfig && m.elo !== null) {
|
||||
newWins[m.participantId] = (simulatorConfig.projectionInput === 'tablePoints'
|
||||
? eloToProjectedTablePoints(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||||
: eloToProjectedWins(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||||
).toFixed(1);
|
||||
}
|
||||
}
|
||||
setEloValues(newElos);
|
||||
|
|
@ -523,10 +489,7 @@ Mark Selby, 2432`
|
|||
<div key={m.participantId} className="flex justify-between px-3 py-1.5">
|
||||
<span className="text-muted-foreground">{m.inputName}</span>
|
||||
<span className="font-medium">
|
||||
{m.name} →{' '}
|
||||
{m.projection !== null
|
||||
? `${m.projection} ${projectionUnit} (Elo ${m.elo})`
|
||||
: m.elo !== null ? `Elo ${m.elo}` : 'No Elo'}
|
||||
{m.name} → {m.elo !== null ? `Elo ${m.elo}` : 'No Elo'}
|
||||
{usesRanking && m.ranking !== null ? `, ${rankLabel} #${m.ranking}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -546,9 +509,7 @@ Mark Selby, 2432`
|
|||
<div key={u.inputName} className="flex justify-between px-3 py-1.5">
|
||||
<span>{u.inputName}</span>
|
||||
<span className="font-medium">
|
||||
{u.projection !== null
|
||||
? `${u.projection} ${projectionUnit} (Elo ${u.elo})`
|
||||
: u.elo !== null ? `Elo ${u.elo}` : 'No Elo'}
|
||||
{u.elo !== null ? `Elo ${u.elo}` : 'No Elo'}
|
||||
{usesRanking && u.ranking !== null ? `, ${rankLabel} #${u.ranking}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -579,7 +540,7 @@ Mark Selby, 2432`
|
|||
</CardTitle>
|
||||
<CardDescription>
|
||||
{inputMode === 'projectedWins'
|
||||
? `Enter each team's projected total season ${projectionUnit} — the number you enter is stored as-is and re-derives the Elo on every run. Mid-season it is treated as a projected final total, so the simulation spreads the difference over the games still to play. Saving will run the simulation and update expected values.`
|
||||
? `Enter each team's projected total season ${projectionUnit}. Converted to Elo automatically. Saving will run the simulation and update expected values.`
|
||||
: usesRanking
|
||||
? `Enter each ${participantLabel.toLowerCase()}'s Elo${allowsRankOnly ? ' (optional)' : ''} and ${rankLabel}. Saving will automatically run the simulation and update expected values.`
|
||||
: `Enter each ${participantLabel.toLowerCase()}'s current Elo rating. Saving will automatically run the simulation and update expected values.`}
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
/**
|
||||
* Pure helpers for the bracket-scoring route action, extracted so the
|
||||
* knockout-detection logic can be unit-tested without the full action harness.
|
||||
*/
|
||||
|
||||
/**
|
||||
* From a set of matches being scored this action, return the season_participant
|
||||
* ids knocked out for the FIRST time. A loser counts only when its match was NOT
|
||||
* already complete — a re-score/correction of a finished match must not
|
||||
* re-announce the exit (mirrors populateBracketFromDraw's first-completion rule in
|
||||
* the live-sync path). Entries with an unknown loser (`null`) are skipped.
|
||||
*
|
||||
* The ids are season_participant ids of the primary window (playoff_matches are
|
||||
* keyed by season_participant); the fan-out translates them to each mirror
|
||||
* window's own season_participant id before announcing the "Knocked Out" section.
|
||||
*/
|
||||
export function newlyDecidedLosers(
|
||||
entries: Array<{ wasComplete: boolean; loserId: string | null }>
|
||||
): string[] {
|
||||
return entries
|
||||
.filter((e): e is { wasComplete: boolean; loserId: string } =>
|
||||
!e.wasComplete && e.loserId !== null
|
||||
)
|
||||
.map((e) => e.loserId);
|
||||
}
|
||||
|
|
@ -1,23 +1,16 @@
|
|||
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
|
||||
|
||||
import { findSportsSeasonById } from "~/models/sports-season";
|
||||
import {
|
||||
findParticipantsBySportsSeasonId,
|
||||
createParticipant,
|
||||
updateParticipant,
|
||||
} from "~/models/season-participant";
|
||||
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
|
||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
||||
import { getScoringEventById, updateScoringEvent } from "~/models/scoring-event";
|
||||
import {
|
||||
findPlayoffMatchesByEventId,
|
||||
deletePlayoffMatchesByEventId,
|
||||
generateBracketFromTemplate,
|
||||
setMatchWinner,
|
||||
advanceWinnerTemplate,
|
||||
findPlayoffMatchById,
|
||||
assignParticipantsToKnockout,
|
||||
doesLoserAdvance,
|
||||
reseedAflEliminationFinals,
|
||||
reseedAflSemiFinals,
|
||||
} from "~/models/playoff-match";
|
||||
import {
|
||||
createGame,
|
||||
|
|
@ -32,22 +25,13 @@ import {
|
|||
import {
|
||||
processPlayoffEvent,
|
||||
processMatchResult,
|
||||
processQualifyingBracketEvent,
|
||||
processQualifyingEvent,
|
||||
finalizeQualifyingPoints,
|
||||
recalculateAffectedLeagues,
|
||||
recalculateStandings,
|
||||
autoCompleteRoundIfDone,
|
||||
applyBracketEntryFloors,
|
||||
} from "~/models/scoring-calculator";
|
||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
|
||||
import {
|
||||
setParticipantResult,
|
||||
findParticipantResultsBySportsSeasonId,
|
||||
deleteParticipantResultsBySportsSeasonId,
|
||||
deleteParticipantResultsForParticipants,
|
||||
} from "~/models/participant-result";
|
||||
import { setParticipantResult, findParticipantResultsBySportsSeasonId } from "~/models/participant-result";
|
||||
import { findSeasonSportsBySportsSeasonId } from "~/models/season-sport";
|
||||
import { createDailySnapshot } from "~/models/standings";
|
||||
import {
|
||||
|
|
@ -70,10 +54,6 @@ import { database } from "~/database/context";
|
|||
import * as schema from "~/database/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
|
||||
import { fanOutMajorIfPrimary, syncMajorFromPrimaryEvent } from "~/services/sync-tournament-results";
|
||||
import { syncTennisDraw, previewTennisDraw } from "~/services/match-sync";
|
||||
import { articleTitleFromInput } from "~/services/match-sync/wikipedia-tennis";
|
||||
import { newlyDecidedLosers } from "./admin.sports-seasons.$id.events.$eventId.bracket.helpers";
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
const sportsSeason = await findSportsSeasonById(params.id);
|
||||
|
|
@ -123,230 +103,10 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a qualifying-event bracket (e.g. CS2 Champions Stage): derive each team's
|
||||
* guaranteed-minimum QP from the bracket and refresh affected league standings.
|
||||
*
|
||||
* Qualifying brackets award QUALIFYING POINTS, not fantasy points, so this never
|
||||
* writes seasonParticipantResults and never records team_score_events — match
|
||||
* results surface via the QP Standings and the Discord standings update. Final
|
||||
* fantasy placements come from finalizeQualifyingPoints across all majors.
|
||||
*/
|
||||
async function scoreQualifyingBracket(
|
||||
event: {
|
||||
id: string;
|
||||
sportsSeasonId: string;
|
||||
name: string | null;
|
||||
isPrimary: boolean;
|
||||
tournamentId: string | null;
|
||||
},
|
||||
db: ReturnType<typeof database>,
|
||||
recalcOptions?: Parameters<typeof recalculateAffectedLeagues>[2],
|
||||
/**
|
||||
* Season_participant ids of players knocked out for the first time by this
|
||||
* operation (losers of matches that just reached completion). Forwarded to the
|
||||
* fan-out so every mirror window announces the "Knocked Out" section — a
|
||||
* non-scoring-round exit earns no QP and is otherwise invisible to the mirror.
|
||||
* Empty for re-scores/reprocesses, which decide no new losers.
|
||||
*/
|
||||
newlyEliminatedParticipantIds?: Set<string>
|
||||
): Promise<void> {
|
||||
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent),
|
||||
// recalcs participant QP totals, AND announces the QP change to this window's leagues.
|
||||
// Calling processQualifyingBracketEvent directly here would score silently — the QP
|
||||
// Discord notification only fires from processQualifyingEvent. The fan-out below skips
|
||||
// this (primary) window via skipEventId, so mirror windows are announced separately
|
||||
// with no double-post.
|
||||
await processQualifyingEvent(event.id, db, { newlyEliminatedParticipantIds });
|
||||
await recalculateAffectedLeagues(
|
||||
event.sportsSeasonId,
|
||||
db,
|
||||
recalcOptions ?? { eventId: event.id, eventName: event.name ?? undefined }
|
||||
);
|
||||
// If this is the shared major's primary window, propagate to siblings.
|
||||
// Mid-tournament (a single round): don't mark complete yet.
|
||||
await fanOutMajorIfPrimary(event, {
|
||||
markComplete: false,
|
||||
newlyEliminatedParticipantIds,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the given participants as eliminated (finalPosition = 0) and, for fantasy
|
||||
* (non-qualifying) events, announce the teams newly eliminated by this run to the
|
||||
* affected leagues' Discord channels.
|
||||
*
|
||||
* "Newly eliminated" = participants with no prior result row, so re-running a
|
||||
* generation step never re-announces the same teams. The announcement is a
|
||||
* best-effort side effect: a failure must not fail the generation action, since
|
||||
* the eliminations themselves are already committed. eventId is deliberately
|
||||
* omitted from the recalc call so the announcement doesn't pull in unrelated
|
||||
* completed matches as "Scored Matches".
|
||||
*
|
||||
* Returns the number of participants marked alongside whether a standings recalculation
|
||||
* actually ran. The caller banks entry floors before calling this and needs them to
|
||||
* reach teamStandings.totalPoints; it cannot infer that from the participant count,
|
||||
* because the recalc is skipped for qualifying events, when every eliminated team
|
||||
* already had a result row (the second run of a generation), and when the announcement
|
||||
* threw. `recalculated` reports the fact rather than making the caller re-derive it.
|
||||
*/
|
||||
async function markEliminatedAndAnnounce(
|
||||
event: { id: string; name: string | null; sportsSeasonId: string; isQualifyingEvent: boolean },
|
||||
participantIds: string[]
|
||||
): Promise<{ markedCount: number; recalculated: boolean }> {
|
||||
const existingResults = await findParticipantResultsBySportsSeasonId(event.sportsSeasonId);
|
||||
const alreadyHadResult = new Set(existingResults.map((r) => r.participantId));
|
||||
const newlyEliminatedIds = participantIds.filter((id) => !alreadyHadResult.has(id));
|
||||
|
||||
for (const participantId of participantIds) {
|
||||
await setParticipantResult(participantId, event.sportsSeasonId, 0);
|
||||
}
|
||||
|
||||
let recalculated = false;
|
||||
|
||||
// QPs (e.g. tennis/CS2 majors) don't get elimination announcements.
|
||||
if (!event.isQualifyingEvent && newlyEliminatedIds.length > 0) {
|
||||
try {
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, database(), {
|
||||
eventName: event.name ?? undefined,
|
||||
eliminatedParticipantIds: newlyEliminatedIds,
|
||||
});
|
||||
recalculated = true;
|
||||
} catch (err) {
|
||||
logger.error("[Eliminations] Discord announcement failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
return { markedCount: participantIds.length, recalculated };
|
||||
}
|
||||
|
||||
export async function action({ request, params }: Route.ActionArgs) {
|
||||
const formData = await request.formData();
|
||||
const intent = formData.get("intent");
|
||||
|
||||
// Brackets are built/scored only on the major's primary window. A
|
||||
// tournament-linked, non-primary event is a read-only mirror that receives
|
||||
// results via fan-out, so reject every mutating action here.
|
||||
{
|
||||
const ev = await getScoringEventById(params.eventId);
|
||||
if (ev && isReadOnlySibling(ev)) {
|
||||
return {
|
||||
error:
|
||||
"This bracket belongs to a shared major. Build and score it on the primary window (linked from Admin → Tournaments); results fan out here automatically.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "create-draw-participant") {
|
||||
const name = (formData.get("name") as string | null)?.trim();
|
||||
const externalId = (formData.get("externalId") as string | null)?.trim() || null;
|
||||
if (!name) return { error: "Participant name is required" };
|
||||
try {
|
||||
await createParticipant({ sportsSeasonId: params.id, name, externalId });
|
||||
return { success: `Created "${name}". Re-run Preview or Sync Draw to apply.` };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Failed to create participant" };
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "relink-draw-participant") {
|
||||
const participantId = formData.get("participantId") as string | null;
|
||||
const name = (formData.get("name") as string | null)?.trim();
|
||||
const externalId = (formData.get("externalId") as string | null)?.trim() || null;
|
||||
if (!participantId || !name) return { error: "Participant and name are required" };
|
||||
try {
|
||||
await updateParticipant(participantId, { name, externalId });
|
||||
return { success: `Renamed and linked to "${name}". Re-run Preview or Sync Draw to apply.` };
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : "Failed to update participant" };
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "preview-draw") {
|
||||
const rawInput = (formData.get("externalSourceKey") as string | null)?.trim();
|
||||
const articleTitle = rawInput ? articleTitleFromInput(rawInput) : "";
|
||||
// Persist the article so the user can preview, then sync, without re-entering.
|
||||
if (articleTitle) {
|
||||
await updateScoringEvent(params.eventId, { externalSourceKey: articleTitle });
|
||||
}
|
||||
try {
|
||||
const preview = await previewTennisDraw(params.eventId);
|
||||
return { drawPreview: preview };
|
||||
} catch (error) {
|
||||
logger.error("[preview-draw] error:", error);
|
||||
return { error: error instanceof Error ? error.message : "Failed to preview draw" };
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "sync-draw") {
|
||||
const rawInput = (formData.get("externalSourceKey") as string | null)?.trim();
|
||||
// Accept a pasted Wikipedia URL or a plain article title; store the title.
|
||||
const articleTitle = rawInput ? articleTitleFromInput(rawInput) : "";
|
||||
if (articleTitle) {
|
||||
await updateScoringEvent(params.eventId, { externalSourceKey: articleTitle });
|
||||
}
|
||||
try {
|
||||
const result = await syncTennisDraw(params.eventId);
|
||||
const reviewNote =
|
||||
result.unmatched.length > 0
|
||||
? ` ${result.unmatched.length} auto-created player(s) need a quick review for possible duplicates.`
|
||||
: "";
|
||||
return {
|
||||
success:
|
||||
`Synced draw: ${result.matchesWritten} matches written ` +
|
||||
`(${result.completed} completed), ${result.participantsCreated} participant(s) added.${reviewNote}`,
|
||||
drawSyncResult: result,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("[sync-draw] error:", error);
|
||||
return { error: error instanceof Error ? error.message : "Failed to sync draw" };
|
||||
}
|
||||
}
|
||||
|
||||
// The only way to repair a mis-seeded bracket: nothing else can rewrite a match's
|
||||
// participants. Clearing brings back the setup form, so the admin re-seeds from there.
|
||||
if (intent === "clear-bracket") {
|
||||
try {
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
if (!event) return { error: "Event not found" };
|
||||
|
||||
const existing = await findPlayoffMatchesByEventId(params.eventId);
|
||||
if (existing.length === 0) {
|
||||
return { error: "This event has no bracket to clear" };
|
||||
}
|
||||
// Clearing discards recorded results, so make the admin confirm once games have
|
||||
// actually been played.
|
||||
const completed = existing.filter((m) => m.isComplete).length;
|
||||
if (completed > 0 && formData.get("confirm") !== "true") {
|
||||
return {
|
||||
error: `This bracket has ${completed} completed match(es). Confirm to discard those results.`,
|
||||
};
|
||||
}
|
||||
|
||||
await deletePlayoffMatchesByEventId(params.eventId);
|
||||
|
||||
// Placements are deliberately left alone. seasonParticipantResults is keyed by
|
||||
// sports season, not by event, so a season-wide delete here would wipe the
|
||||
// placements of every other event in the season with nothing to rebuild them —
|
||||
// and on a finalized qualifying season that means permanently zeroed standings.
|
||||
// Reprocess Bracket already rebuilds placements correctly, qualifying path
|
||||
// included, so point the admin at it once the new bracket is in place.
|
||||
const note =
|
||||
completed > 0
|
||||
? " Run Reprocess Bracket after rebuilding to clear the placements those results produced."
|
||||
: "";
|
||||
|
||||
return {
|
||||
success: `Bracket cleared (${existing.length} match(es) removed). Set it up again below.${note}`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Error clearing bracket:", error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : "Failed to clear bracket",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "generate-bracket") {
|
||||
const templateId = formData.get("templateId");
|
||||
|
||||
|
|
@ -410,50 +170,36 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
try {
|
||||
await generateBracketFromTemplate(params.eventId, templateId, participantIds, regionOverride);
|
||||
|
||||
// The template ID has to land on the event before entry floors can be derived
|
||||
// (getBracketEntryFloor reads it), so persist it here rather than after the
|
||||
// elimination pass below.
|
||||
// PHASE 5.3: Mark participants NOT in the bracket as eliminated
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
if (event) {
|
||||
// Get all participants in the sports season
|
||||
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
||||
|
||||
// Create a set of participants in the bracket for fast lookup
|
||||
const participantsInBracket = new Set(participantIds);
|
||||
|
||||
// Mark participants NOT in the bracket as eliminated
|
||||
for (const participant of allParticipants) {
|
||||
if (!participantsInBracket.has(participant.id)) {
|
||||
await setParticipantResult(
|
||||
participant.id,
|
||||
event.sportsSeasonId,
|
||||
0 // 0 = didn't make playoffs, eliminated
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.log(`[BracketGeneration] Marked ${allParticipants.length - participantIds.length} participants as eliminated`);
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
// Some seedings guarantee points before a ball is bounced — an AFL top-4 seed
|
||||
// has the double chance, so the 5th-6th tier is locked in at generation. Bank
|
||||
// those provisional floors now, ahead of the elimination announcement below so
|
||||
// the standings it posts already reflect them.
|
||||
const entryFloorCount = await applyBracketEntryFloors(params.eventId);
|
||||
if (entryFloorCount > 0) {
|
||||
logger.log(`[BracketGeneration] Applied entry floors to ${entryFloorCount} participant(s)`);
|
||||
}
|
||||
|
||||
// PHASE 5.3: Mark participants NOT in the bracket as eliminated (and announce).
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
if (event) {
|
||||
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
||||
const participantsInBracket = new Set(participantIds);
|
||||
const toEliminate = allParticipants
|
||||
.filter((p) => !participantsInBracket.has(p.id))
|
||||
.map((p) => p.id);
|
||||
const { markedCount, recalculated } = await markEliminatedAndAnnounce(event, toEliminate);
|
||||
logger.log(`[BracketGeneration] Marked ${markedCount} participants as eliminated`);
|
||||
|
||||
// The floors banked above only reach teamStandings.totalPoints via a recalc, and
|
||||
// markEliminatedAndAnnounce runs one for its announcement in some cases but not
|
||||
// others: not for a qualifying event, not when every eliminated team already had
|
||||
// a result row (the second run of a generation, since the first wrote 0 for all
|
||||
// of them), not when there was nobody to eliminate, and not when the announcement
|
||||
// threw. Drive off what it reports rather than re-deriving it from toEliminate.
|
||||
// skipDiscord: seeding floors are not a result to announce.
|
||||
if (entryFloorCount > 0 && !recalculated) {
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, database(), {
|
||||
eventName: event.name ?? undefined,
|
||||
skipDiscord: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { success: "Bracket generated successfully" };
|
||||
} catch (error) {
|
||||
logger.error("Error generating bracket:", error);
|
||||
|
|
@ -500,13 +246,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
return { error: "Could not determine loser" };
|
||||
}
|
||||
|
||||
// A knockout is "newly decided" only when the match wasn't already complete
|
||||
// (mirrors populateBracketFromDraw's first-completion rule) — a re-score/
|
||||
// correction of an already-finished match must not re-announce the exit.
|
||||
const setWinnerNewlyEliminated = new Set(
|
||||
newlyDecidedLosers([{ wasComplete: match.isComplete, loserId }])
|
||||
);
|
||||
|
||||
// Set the winner
|
||||
await setMatchWinner(matchId, winnerId, loserId);
|
||||
|
||||
|
|
@ -528,42 +267,26 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
}
|
||||
|
||||
// Immediately score this match: loser gets their final placement,
|
||||
// winner gets provisional floor points (isPartialScore=true).
|
||||
// Prefer the template-defined isScoring over the DB field: the DB column
|
||||
// defaults to true, so legacy play-in rows may be incorrectly marked as scoring.
|
||||
const setWinnerRoundIsScoring = setWinnerTemplate?.rounds.find((r) => r.name === match.round)?.isScoring;
|
||||
await processMatchResult({
|
||||
round: match.round,
|
||||
winnerId,
|
||||
loserId,
|
||||
isScoring: setWinnerRoundIsScoring !== undefined ? setWinnerRoundIsScoring : (match.isScoring ?? true),
|
||||
sportsSeasonId: event.sportsSeasonId,
|
||||
bracketTemplateId: event.bracketTemplateId,
|
||||
eventId: event.id,
|
||||
eventName: event.name ?? undefined,
|
||||
matchId,
|
||||
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
|
||||
});
|
||||
|
||||
const db = database();
|
||||
|
||||
if (event.isQualifyingEvent) {
|
||||
// Qualifying major (e.g. CS2): bracket results award QUALIFYING POINTS, not
|
||||
// fantasy points. matchIds scopes the Discord notification to just this match.
|
||||
await scoreQualifyingBracket(
|
||||
event,
|
||||
db,
|
||||
{
|
||||
eventId: event.id,
|
||||
eventName: event.name ?? undefined,
|
||||
matchIds: [matchId],
|
||||
},
|
||||
setWinnerNewlyEliminated
|
||||
);
|
||||
} else {
|
||||
// Immediately score this match: loser gets their final placement,
|
||||
// winner gets provisional floor points (isPartialScore=true).
|
||||
// Prefer the template-defined isScoring over the DB field: the DB column
|
||||
// defaults to true, so legacy play-in rows may be incorrectly marked as scoring.
|
||||
const setWinnerRoundIsScoring = setWinnerTemplate?.rounds.find((r) => r.name === match.round)?.isScoring;
|
||||
await processMatchResult({
|
||||
round: match.round,
|
||||
winnerId,
|
||||
loserId,
|
||||
isScoring: setWinnerRoundIsScoring !== undefined ? setWinnerRoundIsScoring : (match.isScoring ?? true),
|
||||
sportsSeasonId: event.sportsSeasonId,
|
||||
bracketTemplateId: event.bracketTemplateId,
|
||||
eventId: event.id,
|
||||
eventName: event.name ?? undefined,
|
||||
matchId,
|
||||
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
|
||||
});
|
||||
|
||||
await autoCompleteRoundIfDone(event.id, match.round, event.sportsSeasonId, db);
|
||||
}
|
||||
await autoCompleteRoundIfDone(event.id, match.round, event.sportsSeasonId, db);
|
||||
|
||||
return { success: "Winner set successfully" };
|
||||
} catch (error) {
|
||||
|
|
@ -614,10 +337,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
let successCount = 0;
|
||||
const errors: string[] = [];
|
||||
const processedMatchIds: string[] = [];
|
||||
// Per-match completion + loser, collected so newlyDecidedLosers() can pick out
|
||||
// the batch's first-time knockouts to fan out to mirror windows (a non-scoring-
|
||||
// round exit earns no QP and is otherwise invisible to the mirror).
|
||||
const decidedEntries: Array<{ wasComplete: boolean; loserId: string | null }> = [];
|
||||
|
||||
for (const { matchId, winnerId } of winnerAssignments) {
|
||||
try {
|
||||
|
|
@ -666,30 +385,22 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
|
||||
// Score this match without triggering standings/probability recalc yet —
|
||||
// we batch those side effects into a single call after the loop.
|
||||
// Qualifying majors derive QP from the whole bracket once after the loop
|
||||
// (processQualifyingBracketEvent), so skip the per-match fantasy scoring here.
|
||||
if (!event.isQualifyingEvent) {
|
||||
await processMatchResult({
|
||||
round: match.round,
|
||||
winnerId,
|
||||
loserId,
|
||||
isScoring: roundIsScoring.get(match.round) ?? (match.isScoring ?? true),
|
||||
sportsSeasonId: event.sportsSeasonId,
|
||||
bracketTemplateId: event.bracketTemplateId,
|
||||
eventId: event.id,
|
||||
eventName: event.name ?? undefined,
|
||||
matchId,
|
||||
skipSideEffects: true,
|
||||
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
|
||||
});
|
||||
}
|
||||
await processMatchResult({
|
||||
round: match.round,
|
||||
winnerId,
|
||||
loserId,
|
||||
isScoring: roundIsScoring.get(match.round) ?? (match.isScoring ?? true),
|
||||
sportsSeasonId: event.sportsSeasonId,
|
||||
bracketTemplateId: event.bracketTemplateId,
|
||||
eventId: event.id,
|
||||
eventName: event.name ?? undefined,
|
||||
matchId,
|
||||
skipSideEffects: true,
|
||||
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
|
||||
});
|
||||
|
||||
successCount++;
|
||||
processedMatchIds.push(matchId);
|
||||
// Only after the match fully succeeded: record its prior completion so
|
||||
// newlyDecidedLosers() announces this loser only if it's a first-time exit
|
||||
// (and never for a match whose write failed above).
|
||||
decidedEntries.push({ wasComplete: match.isComplete, loserId });
|
||||
} catch (error) {
|
||||
logger.error(`Error setting winner for match ${matchId}:`, error);
|
||||
errors.push(
|
||||
|
|
@ -703,15 +414,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
// previously completed matches in the event.
|
||||
if (successCount > 0) {
|
||||
const db = database();
|
||||
// Qualifying majors: derive QP from the full bracket once for the batch, and
|
||||
// announce the QP change to this window's leagues. processQualifyingEvent (not
|
||||
// processQualifyingBracketEvent) is what sends the QP Discord notification; the
|
||||
// fan-out below skips this window (skipEventId) so mirrors don't double-post.
|
||||
if (event.isQualifyingEvent) {
|
||||
await processQualifyingEvent(event.id, db, {
|
||||
newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)),
|
||||
});
|
||||
}
|
||||
// Update probabilities first so recalculateAffectedLeagues reads fresh EVs
|
||||
// when computing projected points.
|
||||
try {
|
||||
|
|
@ -720,18 +422,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
logger.error(`Error updating probabilities after batch round winners:`, error);
|
||||
}
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventId: event.id, eventName: event.name ?? undefined, matchIds: processedMatchIds });
|
||||
// autoCompleteRoundIfDone runs processPlayoffEvent (fantasy path); skip for
|
||||
// qualifying majors, which are scored via processQualifyingBracketEvent above.
|
||||
if (!event.isQualifyingEvent) {
|
||||
await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db);
|
||||
} else {
|
||||
// Shared major primary window: propagate this round to siblings, carrying
|
||||
// the batch's newly-decided knockouts so mirrors announce them too.
|
||||
await fanOutMajorIfPrimary(event, {
|
||||
markComplete: false,
|
||||
newlyEliminatedParticipantIds: new Set(newlyDecidedLosers(decidedEntries)),
|
||||
});
|
||||
}
|
||||
await autoCompleteRoundIfDone(event.id, round, event.sportsSeasonId, db);
|
||||
}
|
||||
|
||||
if (errors.length > 0 && successCount === 0) {
|
||||
|
|
@ -783,14 +474,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
return { error: `Not all matches in ${round} are complete` };
|
||||
}
|
||||
|
||||
// Qualifying majors (e.g. CS2): QP is derived directly from the bracket via
|
||||
// processQualifyingBracketEvent — there are no seasonParticipantResults rows to
|
||||
// validate against, and final fantasy placements come from finalizeQualifyingPoints.
|
||||
if (event.isQualifyingEvent) {
|
||||
await scoreQualifyingBracket(event, database());
|
||||
return { success: `${round} completed and qualifying points updated` };
|
||||
}
|
||||
|
||||
// Validate round order: ensure previous rounds are complete
|
||||
const existingResults = await findParticipantResultsBySportsSeasonId(
|
||||
params.id
|
||||
|
|
@ -868,101 +551,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
}
|
||||
|
||||
// Re-seed the AFL Wildcard winners into the Elimination Finals they belong in.
|
||||
// Advancement does this on every Wildcard result, so this is only needed for a
|
||||
// bracket advanced before that rule existed: the winners sit in the wrong games and
|
||||
// no admin action re-runs advancement (a completed match cannot be re-submitted).
|
||||
if (intent === "reseed-afl-wildcard") {
|
||||
try {
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
if (!event) return { error: "Event not found" };
|
||||
if (event.bracketTemplateId !== "afl_10") {
|
||||
return { error: "This action only applies to AFL finals brackets" };
|
||||
}
|
||||
|
||||
const participants = await findParticipantsBySportsSeasonId(params.id);
|
||||
const nameOf = (id: string) => participants.find((p) => p.id === id)?.name ?? id;
|
||||
|
||||
const reseed = await reseedAflEliminationFinals(params.eventId);
|
||||
if (reseed.vacated.length === 0 && reseed.filled.length === 0) {
|
||||
return {
|
||||
success:
|
||||
"Elimination Finals already match the Wildcard results — nothing to re-seed.",
|
||||
};
|
||||
}
|
||||
|
||||
// Only the qualifier slots move, so there is nothing to re-score: no placement,
|
||||
// score or elimination changes, and so nothing to announce.
|
||||
const moves = reseed.filled
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber)
|
||||
.map((slot) => `match ${slot.matchNumber} now hosts ${nameOf(slot.participantId)}`)
|
||||
.join(", ");
|
||||
|
||||
return {
|
||||
success: `Re-seeded the Elimination Finals: ${moves}.`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Error re-seeding AFL Wildcard winners:", error);
|
||||
return {
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to re-seed the Elimination Finals",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Put the Elimination Final winners in the Semi-Finals they belong in. Elimination
|
||||
// Final n feeds Semi-Final n, but brackets advanced before that was fixed crossed the
|
||||
// two winners, and no admin action re-runs advancement (a completed match cannot be
|
||||
// re-submitted).
|
||||
if (intent === "reseed-afl-semifinals") {
|
||||
try {
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
if (!event) return { error: "Event not found" };
|
||||
if (event.bracketTemplateId !== "afl_10") {
|
||||
return { error: "This action only applies to AFL finals brackets" };
|
||||
}
|
||||
|
||||
const participants = await findParticipantsBySportsSeasonId(params.id);
|
||||
const nameOf = (id: string) => participants.find((p) => p.id === id)?.name ?? id;
|
||||
|
||||
const reseed = await reseedAflSemiFinals(params.eventId);
|
||||
if (reseed.vacated.length === 0 && reseed.filled.length === 0) {
|
||||
return {
|
||||
success:
|
||||
"Semi-Finals already match the Elimination Finals results — nothing to re-seed.",
|
||||
};
|
||||
}
|
||||
|
||||
// Only the qualifier slots move, so there is nothing to re-score: no placement,
|
||||
// score or elimination changes, and so nothing to announce.
|
||||
//
|
||||
// A slot can be vacated without being refilled — un-recording an Elimination Final
|
||||
// result takes its winner back out — so report those too rather than rendering an
|
||||
// empty list.
|
||||
const filled = reseed.filled.map((slot) => ({
|
||||
matchNumber: slot.matchNumber,
|
||||
text: `match ${slot.matchNumber} now hosts ${nameOf(slot.participantId)}`,
|
||||
}));
|
||||
const emptied = reseed.vacated
|
||||
.filter((matchNumber) => !reseed.filled.some((slot) => slot.matchNumber === matchNumber))
|
||||
.map((matchNumber) => ({ matchNumber, text: `match ${matchNumber} is back to TBD` }));
|
||||
const moves = [...filled, ...emptied]
|
||||
.toSorted((a, b) => a.matchNumber - b.matchNumber)
|
||||
.map((move) => move.text)
|
||||
.join(", ");
|
||||
|
||||
return {
|
||||
success: `Re-seeded the Semi-Finals: ${moves}.`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Error re-seeding AFL Elimination Finals winners:", error);
|
||||
return {
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to re-seed the Semi-Finals",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "reprocess-bracket") {
|
||||
try {
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
|
|
@ -971,86 +559,19 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
const matches = await findPlayoffMatchesByEventId(params.eventId);
|
||||
const completed = matches.filter((m) => m.isComplete && m.winnerId && m.loserId);
|
||||
|
||||
// Qualifying majors: this is also the cleanup tool for majors that wrongly banked
|
||||
// fantasy points under the old path. Delete the stale seasonParticipantResults rows
|
||||
// (erasing those points), then rebuild correct QP from the bracket. Qualifying sports
|
||||
// have no legitimate per-major fantasy placements — those come from
|
||||
// finalizeQualifyingPoints across all majors.
|
||||
if (event.isQualifyingEvent) {
|
||||
const db = database();
|
||||
await deleteParticipantResultsBySportsSeasonId(event.sportsSeasonId, db);
|
||||
await processQualifyingBracketEvent(params.eventId, db);
|
||||
// If the season's QP was already finalized, the delete above wiped the final
|
||||
// placements — recompute them from QP totals so standings aren't left blank.
|
||||
const sportsSeason = await findSportsSeasonById(params.id);
|
||||
if (sportsSeason?.qualifyingPointsFinalized) {
|
||||
await finalizeQualifyingPoints(event.sportsSeasonId, db); // recalcs leagues itself
|
||||
} else {
|
||||
// skipDiscord: reprocess is a data-correction tool, not a result announcement.
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, db, { skipDiscord: true });
|
||||
}
|
||||
// Re-propagate corrected QP to sibling/mirror windows (data-correction; not
|
||||
// final). Call syncMajorFromPrimaryEvent directly rather than the
|
||||
// swallow-and-log fanOutMajorIfPrimary so the admin actually sees whether the
|
||||
// mirrors were re-scored — a silent failure here is exactly how mirrors got
|
||||
// left showing stale QP behind a green "success".
|
||||
const baseMessage = `Reprocessed qualifying bracket: cleared stale fantasy points and recomputed QP (${completed.length} completed match(es)).`;
|
||||
if (event.isPrimary && event.tournamentId) {
|
||||
try {
|
||||
const report = await syncMajorFromPrimaryEvent(event.id, { markComplete: false });
|
||||
// Surface a partial fan-out as an error so it renders as a warning
|
||||
// banner, not a green success the admin might skim past while some
|
||||
// mirror windows are left stale.
|
||||
if (report.windowsFailed > 0) {
|
||||
const reasons = report.failures.map((f) => f.error).join("; ");
|
||||
return {
|
||||
error: `${baseMessage} Synced ${report.windowsSynced} mirror window(s), but ${report.windowsFailed} failed (those windows may be stale): ${reasons}`,
|
||||
};
|
||||
}
|
||||
return { success: `${baseMessage} Synced ${report.windowsSynced} mirror window(s).` };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Primary window recomputed, but mirror fan-out failed (mirror windows may be stale): ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { success: `${baseMessage} No mirror windows to sync.` };
|
||||
if (completed.length === 0) {
|
||||
return { error: "No completed matches to reprocess" };
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
return { error: "No bracket to reprocess" };
|
||||
}
|
||||
|
||||
// Wipe this bracket's participants' results and rebuild from scratch. Deleting
|
||||
// only the partial rows would leave stale finalized ones, which the "never
|
||||
// un-finalize" guard in upsertParticipantResult then refuses to correct.
|
||||
//
|
||||
// Scoped to the participants this bracket actually holds, not the whole season:
|
||||
// seasonParticipantResults is keyed by sports season, not by event, so a
|
||||
// season-wide delete takes every other event's placements with it and only this
|
||||
// bracket's replay could rebuild them (the hazard clear-bracket documents).
|
||||
//
|
||||
// Unconditional, because zero completed matches is precisely the clear-bracket →
|
||||
// regenerate → reprocess repair path: the discarded bracket's finalized
|
||||
// placements are exactly what needs clearing, and there is always something to
|
||||
// rebuild from — the entry floors below, then the replay.
|
||||
// Delete ALL results for this sports season and rebuild from scratch.
|
||||
// Only deleting partial rows leaves stale finalized rows that block
|
||||
// the "never un-finalize" guard in upsertParticipantResult.
|
||||
const db = database();
|
||||
// Reused further down to decide who is *not* in the bracket and so eliminated.
|
||||
const bracketParticipantIds = new Set<string>();
|
||||
for (const match of matches) {
|
||||
if (match.participant1Id) bracketParticipantIds.add(match.participant1Id);
|
||||
if (match.participant2Id) bracketParticipantIds.add(match.participant2Id);
|
||||
}
|
||||
await deleteParticipantResultsForParticipants(
|
||||
event.sportsSeasonId,
|
||||
[...bracketParticipantIds],
|
||||
db
|
||||
);
|
||||
|
||||
// Re-bank the seeding-derived floors the delete above wipes (e.g. the AFL
|
||||
// top-4's 5th-6th tier). Done before the replay so real match results overwrite
|
||||
// them; a bracket with no completed matches still gets its guaranteed points.
|
||||
const entryFloorCount = await applyBracketEntryFloors(params.eventId, db);
|
||||
await db
|
||||
.delete(schema.seasonParticipantResults)
|
||||
.where(
|
||||
eq(schema.seasonParticipantResults.sportsSeasonId, event.sportsSeasonId)
|
||||
);
|
||||
|
||||
// Replay each completed match in bracket order (earlier rounds first).
|
||||
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
|
||||
|
|
@ -1091,6 +612,11 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
// Mark participants NOT in any bracket match as eliminated (finalPosition = 0).
|
||||
// This covers teams that didn't make the playoffs/play-in tournament.
|
||||
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
||||
const bracketParticipantIds = new Set<string>();
|
||||
for (const match of matches) {
|
||||
if (match.participant1Id) bracketParticipantIds.add(match.participant1Id);
|
||||
if (match.participant2Id) bracketParticipantIds.add(match.participant2Id);
|
||||
}
|
||||
let eliminatedCount = 0;
|
||||
for (const participant of allParticipants) {
|
||||
if (!bracketParticipantIds.has(participant.id)) {
|
||||
|
|
@ -1106,12 +632,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
// skipDiscord: reprocess-bracket is a data-correction tool, not a result announcement.
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, undefined, { skipDiscord: true });
|
||||
|
||||
return {
|
||||
success:
|
||||
`Reprocessed bracket: ${sortedMatches.length} match(es) replayed, ` +
|
||||
`${entryFloorCount} seeded participant(s) given their guaranteed entry floor, ` +
|
||||
`${eliminatedCount} non-bracket participant(s) eliminated`,
|
||||
};
|
||||
return { success: `Reprocessed bracket: ${sortedMatches.length} match(es) replayed, ${eliminatedCount} non-bracket participant(s) eliminated` };
|
||||
} catch (error) {
|
||||
logger.error("Error reprocessing bracket:", error);
|
||||
return {
|
||||
|
|
@ -1148,31 +669,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
};
|
||||
}
|
||||
|
||||
// Qualifying majors (e.g. CS2): lock in final bracket placements/QP, then run the
|
||||
// standard qualifying finalizer for THIS event. Do not mark the whole sports season
|
||||
// completed or recalc fantasy standings — a season spans multiple majors and final
|
||||
// fantasy placements come from finalizeQualifyingPoints across all of them.
|
||||
if (event.isQualifyingEvent) {
|
||||
const db = database();
|
||||
// processQualifyingEvent derives the bracket QP (via processQualifyingBracketEvent)
|
||||
// and recalcs participant QP totals. majorsCompleted is derived on read from
|
||||
// completed qualifying events (see getMajorsCompleted) — marking this event
|
||||
// complete below is what advances it. Season-wide fantasy finalization stays with
|
||||
// finalizeQualifyingPoints across all majors.
|
||||
await processQualifyingEvent(params.eventId, db);
|
||||
await db
|
||||
.update(schema.scoringEvents)
|
||||
.set({ isComplete: true, completedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(schema.scoringEvents.id, params.eventId));
|
||||
await recalculateAffectedLeagues(event.sportsSeasonId, db, {
|
||||
eventId: params.eventId,
|
||||
eventName: event.name ?? undefined,
|
||||
});
|
||||
// Finalize: propagate to siblings AND mark every window complete.
|
||||
await fanOutMajorIfPrimary(event, { markComplete: true });
|
||||
return { success: "Major bracket finalized — qualifying points awarded." };
|
||||
}
|
||||
|
||||
// Get all participants in this sports season
|
||||
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
||||
|
||||
|
|
@ -1287,19 +783,14 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
await generateBracketFromTemplate(params.eventId, templateId);
|
||||
|
||||
// Eliminate participants from this sport season who are not in any group
|
||||
// (and announce to leagues for fantasy events).
|
||||
const groupsEvent = await getScoringEventById(params.eventId);
|
||||
if (!groupsEvent) {
|
||||
return { error: "Event not found" };
|
||||
}
|
||||
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
||||
const toEliminate = allParticipants
|
||||
.filter((p) => !uniqueParticipants.has(p.id))
|
||||
.map((p) => p.id);
|
||||
const { markedCount: eliminatedCount } = await markEliminatedAndAnnounce(
|
||||
groupsEvent,
|
||||
toEliminate
|
||||
);
|
||||
let eliminatedCount = 0;
|
||||
for (const participant of allParticipants) {
|
||||
if (!uniqueParticipants.has(participant.id)) {
|
||||
await setParticipantResult(participant.id, params.id, 0);
|
||||
eliminatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: `Groups and knockout bracket structure created successfully${eliminatedCount > 0 ? ` (${eliminatedCount} participant(s) not in any group marked as eliminated)` : ""}`,
|
||||
|
|
@ -1424,10 +915,11 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
// Assign participants to knockout bracket
|
||||
await assignParticipantsToKnockout(params.eventId, assignments);
|
||||
|
||||
// Mark eliminated group participants with finalPosition = 0 (and announce
|
||||
// the group-stage eliminations to leagues for fantasy events).
|
||||
// Mark eliminated group participants with finalPosition = 0
|
||||
const eliminatedIds = await getEliminatedParticipantIds(params.eventId);
|
||||
await markEliminatedAndAnnounce(event, eliminatedIds);
|
||||
for (const participantId of eliminatedIds) {
|
||||
await setParticipantResult(participantId, event.sportsSeasonId, 0);
|
||||
}
|
||||
|
||||
logger.log(
|
||||
`[PopulateKnockout] Assigned 32 participants to knockout, marked ${eliminatedIds.length} as eliminated`
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Form, Link, useFetcher } from "react-router";
|
||||
import { Fragment, useState, useEffect, useMemo, useRef } from "react";
|
||||
import { localDateTimeToUtcIso, utcIsoToLocalDateTime } from "~/lib/date-utils";
|
||||
import { Form, Link } from "react-router";
|
||||
import { Fragment, useState, useEffect, useMemo } from "react";
|
||||
import { localDateTimeToUtcIso } from "~/lib/date-utils";
|
||||
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
|
||||
|
||||
import { loader, action } from "./admin.sports-seasons.$id.events.$eventId.bracket.server";
|
||||
|
|
@ -39,116 +39,8 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|||
return [{ title: `Bracket — ${data?.event?.name ?? "Event"} - Brackt Admin` }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule editor for a single group-stage match.
|
||||
*
|
||||
* The admin enters a local wall-clock time; on submit it is converted to a UTC
|
||||
* ISO string (in the hidden `scheduledAt` field) so the DB stores a true UTC
|
||||
* instant. The stored UTC value is rendered back into the input in the
|
||||
* browser's local timezone via a client-only effect (avoids SSR hydration
|
||||
* mismatch, since the server runs in UTC).
|
||||
*/
|
||||
function GroupMatchScheduleForm({
|
||||
match,
|
||||
localTzAbbr,
|
||||
}: {
|
||||
match: { id: string; scheduledAt: string | Date | null | undefined };
|
||||
localTzAbbr: string;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = utcIsoToLocalDateTime(match.scheduledAt);
|
||||
}
|
||||
}, [match.scheduledAt]);
|
||||
return (
|
||||
<Form
|
||||
method="post"
|
||||
className="flex items-center gap-1"
|
||||
onSubmit={(e) => {
|
||||
const form = e.currentTarget;
|
||||
const local = form.elements.namedItem("scheduledAtLocal") as HTMLInputElement;
|
||||
const hidden = form.elements.namedItem("scheduledAt") as HTMLInputElement;
|
||||
if (hidden) hidden.value = localDateTimeToUtcIso(local?.value) ?? "";
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="intent" value="update-group-match-schedule" />
|
||||
<input type="hidden" name="matchId" value={match.id} />
|
||||
{/* Hidden field holds the UTC ISO string written by onSubmit */}
|
||||
<input type="hidden" name="scheduledAt" defaultValue="" />
|
||||
<Calendar className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="datetime-local"
|
||||
name="scheduledAtLocal"
|
||||
title={`Time zone: ${localTzAbbr}`}
|
||||
className="h-6 text-xs px-1 py-0"
|
||||
/>
|
||||
<Button type="submit" size="sm" variant="ghost" className="h-6 px-1.5 text-xs">
|
||||
Save
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export { loader, action };
|
||||
|
||||
/**
|
||||
* One "possible duplicate" row in the draw preview. Uses a fetcher so resolving
|
||||
* it (rename existing / create as new) submits in the background — the preview
|
||||
* stays on screen instead of reloading the page and forcing a fresh dry run.
|
||||
*/
|
||||
function DuplicateRow({
|
||||
p,
|
||||
}: {
|
||||
p: { name: string; externalId: string | null; suggestion: string; suggestionId: string };
|
||||
}) {
|
||||
const fetcher = useFetcher<{ success?: string; error?: string }>();
|
||||
const busy = fetcher.state !== "idle";
|
||||
|
||||
return (
|
||||
<li className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span>
|
||||
<span className="font-medium">{p.name}</span>
|
||||
<span className="text-muted-foreground"> → looks like </span>
|
||||
<span className="font-medium">{p.suggestion}</span>
|
||||
</span>
|
||||
{fetcher.data?.success ? (
|
||||
<span className="text-xs font-medium text-emerald-500">✓ {fetcher.data.success}</span>
|
||||
) : (
|
||||
<fetcher.Form method="post" className="flex items-center gap-2">
|
||||
<input type="hidden" name="name" value={p.name} />
|
||||
<input type="hidden" name="externalId" value={p.externalId ?? ""} />
|
||||
<input type="hidden" name="participantId" value={p.suggestionId} />
|
||||
<Button
|
||||
type="submit"
|
||||
name="intent"
|
||||
value="relink-draw-participant"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
>
|
||||
Rename “{p.suggestion}” → “{p.name}”
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
name="intent"
|
||||
value="create-draw-participant"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
>
|
||||
Create as new
|
||||
</Button>
|
||||
</fetcher.Form>
|
||||
)}
|
||||
{fetcher.data?.error && (
|
||||
<span className="text-xs text-destructive">{fetcher.data.error}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EventBracket({
|
||||
loaderData,
|
||||
actionData,
|
||||
|
|
@ -431,164 +323,6 @@ export default function EventBracket({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Possible-duplicate review after a draw sync */}
|
||||
{actionData?.drawSyncResult && actionData.drawSyncResult.unmatched.length > 0 && (
|
||||
<Card className="border-amber-500/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-amber-500">
|
||||
Review {actionData.drawSyncResult.unmatched.length} possible duplicate
|
||||
{actionData.drawSyncResult.unmatched.length === 1 ? "" : "s"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
These players were auto-created but closely resemble an existing participant —
|
||||
if a duplicate, results won't reach the drafted copy. To fix: on the{" "}
|
||||
<Link
|
||||
to={`/admin/sports-seasons/${sportsSeason.id}/participants`}
|
||||
className="underline font-medium"
|
||||
>
|
||||
participants page
|
||||
</Link>
|
||||
, set the correct participant's external ID to the Wikipedia name, delete the
|
||||
duplicate, then click <span className="font-medium">Sync Draw</span> again.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{actionData.drawSyncResult.unmatched.map((p) => (
|
||||
<li key={p.externalId ?? p.name} className="flex items-center gap-2">
|
||||
<span className="font-medium">{p.name}</span>
|
||||
{p.externalId && p.externalId !== p.name && (
|
||||
<span className="text-muted-foreground text-xs">({p.externalId})</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Dry-run preview of a draw sync */}
|
||||
{actionData?.drawPreview && (
|
||||
<Card className="border-sky-500/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sky-500">Draw preview (no changes made)</CardTitle>
|
||||
<CardDescription>{actionData.drawPreview.article}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 sm:grid-cols-3">
|
||||
<div>
|
||||
Players: <span className="font-medium">{actionData.drawPreview.totalPlayers}</span>
|
||||
</div>
|
||||
<div>
|
||||
Matched:{" "}
|
||||
<span className="font-medium text-emerald-500">
|
||||
{actionData.drawPreview.matched}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Will create:{" "}
|
||||
<span className="font-medium">{actionData.drawPreview.willCreate.length}</span>
|
||||
</div>
|
||||
<div>
|
||||
Matches:{" "}
|
||||
<span className="font-medium">
|
||||
{actionData.drawPreview.completedMatches}/{actionData.drawPreview.totalMatches}{" "}
|
||||
done
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Unfilled R1 slots:{" "}
|
||||
<span className="font-medium">{actionData.drawPreview.tbdFirstRound}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actionData.drawPreview.possibleDuplicates.length > 0 && (
|
||||
<div>
|
||||
<p className="font-medium text-amber-500">
|
||||
Possible duplicates ({actionData.drawPreview.possibleDuplicates.length}) — would
|
||||
be created but resemble an existing participant:
|
||||
</p>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{actionData.drawPreview.possibleDuplicates.map((p) => (
|
||||
<DuplicateRow key={p.externalId ?? p.name} p={p} />
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
<span className="font-medium">Rename</span> if it's the same player (links
|
||||
the existing participant so it matches);{" "}
|
||||
<span className="font-medium">Create as new</span> if they're different
|
||||
people. Then re-run Preview or Sync Draw.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionData.drawPreview.willCreate.length > 0 && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-muted-foreground">
|
||||
Show all {actionData.drawPreview.willCreate.length} players that would be created
|
||||
</summary>
|
||||
<ul className="mt-1 columns-2 sm:columns-3">
|
||||
{actionData.drawPreview.willCreate.map((p) => (
|
||||
<li key={p.externalId ?? p.name}>{p.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Fix any duplicates on the{" "}
|
||||
<Link
|
||||
to={`/admin/sports-seasons/${sportsSeason.id}/participants`}
|
||||
className="underline font-medium"
|
||||
>
|
||||
participants page
|
||||
</Link>{" "}
|
||||
first, then click <span className="font-medium">Sync Draw</span>.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Sync Draw from Wikipedia — tennis Grand Slam auto-populate + auto-score */}
|
||||
{event.isQualifyingEvent &&
|
||||
sportsSeason.sport.simulatorType === "tennis_qualifying_points" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sync Draw from Wikipedia</CardTitle>
|
||||
<CardDescription>
|
||||
Auto-populate and score this Grand Slam bracket from its Wikipedia draw
|
||||
article. Re-run during the tournament to pull in completed matches.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post" className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="externalSourceKey">Wikipedia draw page (URL or title)</Label>
|
||||
<Input
|
||||
id="externalSourceKey"
|
||||
name="externalSourceKey"
|
||||
placeholder="https://en.wikipedia.org/wiki/2025_Wimbledon_Championships_–_Men's_singles"
|
||||
defaultValue={event.externalSourceKey ?? ""}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paste the article URL or type the title — both work. Use{" "}
|
||||
<span className="font-medium">Preview</span> first to see matches and any
|
||||
new/duplicate players before writing anything.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" name="intent" value="preview-draw" variant="outline">
|
||||
Preview (dry run)
|
||||
</Button>
|
||||
<Button type="submit" name="intent" value="sync-draw">
|
||||
Sync Draw
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Reprocess Bracket - Full rebuild of participant results.
|
||||
Hidden once every match is complete — at that point all placements are final
|
||||
and "finalize bracket" should be used instead. */}
|
||||
|
|
@ -613,110 +347,6 @@ export default function EventBracket({
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{/* Re-seed AFL Wildcard winners. Advancement pairs them by ladder position on
|
||||
every Wildcard result, so this is only for a bracket advanced before that
|
||||
rule existed — a completed match cannot be re-submitted to re-run it. */}
|
||||
{event.bracketTemplateId === "afl_10" && matches.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Re-seed Wildcard Winners</CardTitle>
|
||||
<CardDescription>
|
||||
Pair the Elimination Finals by ladder position: 5th hosts the
|
||||
lower-ranked Wildcard winner and 6th the higher-ranked one. Only moves
|
||||
the qualifier slots — no results, scores or placements change, and
|
||||
nothing is announced. Does nothing if the pairings are already right.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="reseed-afl-wildcard" />
|
||||
<Button type="submit" variant="outline">
|
||||
Re-seed Wildcard Winners
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Fix the Semi-Final pairings. Elimination Final n feeds Semi-Final n, but
|
||||
brackets advanced before that was fixed crossed the two winners, and no
|
||||
admin action re-runs advancement. */}
|
||||
{event.bracketTemplateId === "afl_10" && matches.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Fix Semi-Final Pairings</CardTitle>
|
||||
<CardDescription>
|
||||
Feed each Elimination Final into the Semi-Final it belongs to: EF1
|
||||
winner into SF1 and EF2 winner into SF2. Only moves the qualifier slots
|
||||
— no results, scores or placements change, and nothing is announced.
|
||||
Does nothing if the pairings are already right.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="reseed-afl-semifinals" />
|
||||
<Button type="submit" variant="outline">
|
||||
Fix Semi-Final Pairings
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Clear Bracket - the only escape hatch for a mis-seeded bracket. Nothing else
|
||||
can rewrite a match's participants, so a wrong seeding has to be torn down
|
||||
and rebuilt via the setup form below, which reappears once this runs. */}
|
||||
{matches.length > 0 && (
|
||||
<Card className="border-destructive/40">
|
||||
<CardHeader>
|
||||
<CardTitle>Clear Bracket</CardTitle>
|
||||
<CardDescription>
|
||||
Delete every match in this bracket so it can be set up again from
|
||||
scratch. Use this when the wrong participants were seeded. Placements
|
||||
are left alone — run Reprocess Bracket after rebuilding to clear any
|
||||
that the discarded results produced.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form
|
||||
method="post"
|
||||
className="space-y-3"
|
||||
onSubmit={(e) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Delete all ${matches.length} match(es) in this bracket? Recorded results will be lost.`
|
||||
)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="intent" value="clear-bracket" />
|
||||
{/* The server refuses to discard completed matches unless this is
|
||||
checked. Sending it unconditionally from a hidden field would make
|
||||
that guard unreachable, including for a submit without JS. */}
|
||||
{matches.some((m: { isComplete: boolean }) => m.isComplete) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="confirm-clear-bracket"
|
||||
name="confirm"
|
||||
value="true"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label htmlFor="confirm-clear-bracket" className="font-normal">
|
||||
Yes, discard the results already recorded in this bracket
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" variant="destructive">
|
||||
Clear Bracket
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ====== SETUP PHASE ====== */}
|
||||
{showSetup && (
|
||||
<Card>
|
||||
|
|
@ -993,7 +623,7 @@ export default function EventBracket({
|
|||
return (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Label className="w-28 text-sm text-muted-foreground shrink-0">
|
||||
<Label className="w-20 text-sm text-muted-foreground shrink-0">
|
||||
{slotLabel}
|
||||
</Label>
|
||||
<div className="flex-1 min-w-0">
|
||||
|
|
@ -1176,7 +806,24 @@ export default function EventBracket({
|
|||
{groupMatches.map((match) => (
|
||||
<div key={match.id} className="text-xs space-y-1">
|
||||
{/* Schedule row */}
|
||||
<GroupMatchScheduleForm match={match} localTzAbbr={localTzAbbr} />
|
||||
<Form method="post" className="flex items-center gap-1">
|
||||
<input type="hidden" name="intent" value="update-group-match-schedule" />
|
||||
<input type="hidden" name="matchId" value={match.id} />
|
||||
<Calendar className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
<Input
|
||||
type="datetime-local"
|
||||
name="scheduledAt"
|
||||
defaultValue={
|
||||
match.scheduledAt
|
||||
? new Date(match.scheduledAt).toISOString().slice(0, 16)
|
||||
: ""
|
||||
}
|
||||
className="h-6 text-xs px-1 py-0"
|
||||
/>
|
||||
<Button type="submit" size="sm" variant="ghost" className="h-6 px-1.5 text-xs">
|
||||
Save
|
||||
</Button>
|
||||
</Form>
|
||||
{/* Score row */}
|
||||
<Form method="post" className="flex items-center gap-1">
|
||||
<input type="hidden" name="intent" value="update-group-match" />
|
||||
|
|
|
|||
|
|
@ -2,19 +2,18 @@ import { Form, useLoaderData, useActionData, useNavigation, useFetcher, Link } f
|
|||
import type { Route } from './+types/admin.sports-seasons.$id.events.$eventId.cs2-setup';
|
||||
|
||||
import { findSportsSeasonById } from '~/models/sports-season';
|
||||
import { getScoringEventById, isReadOnlySibling } from '~/models/scoring-event';
|
||||
import { getScoringEventById } from '~/models/scoring-event';
|
||||
import { findParticipantsBySportsSeasonId } from '~/models/season-participant';
|
||||
import {
|
||||
getCs2StageResultsForEvent,
|
||||
upsertCs2StageAssignments,
|
||||
markCs2StageEliminations,
|
||||
resetCs2Event,
|
||||
clearCs2StageAssignments,
|
||||
clearCs2EliminationsAtStage,
|
||||
assignCs2EliminationQP,
|
||||
} from '~/models/cs2-major-stage';
|
||||
import { findSeasonMatchesByScoringEventId } from '~/models/season-match';
|
||||
import { syncMatches } from '~/services/match-sync';
|
||||
import { fanOutMajorIfPrimary } from '~/services/sync-tournament-results';
|
||||
import { Button } from '~/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -62,22 +61,9 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
const formData = await request.formData();
|
||||
const intent = formData.get('intent') as string;
|
||||
|
||||
// CS2 stages are set up only on the major's primary window; a tournament-linked
|
||||
// non-primary event is a read-only mirror fed by fan-out.
|
||||
const guardEvent = await getScoringEventById(eventId);
|
||||
if (guardEvent && isReadOnlySibling(guardEvent)) {
|
||||
return {
|
||||
success: false,
|
||||
message:
|
||||
'This CS2 major is set up on its primary window; results fan out here automatically.',
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === 'reset') {
|
||||
// Local recovery tool: clears only the primary's torn state. Siblings are not
|
||||
// touched here — re-entering results re-propagates via the scoring fan-out.
|
||||
await resetCs2Event(eventId, params.id);
|
||||
return { success: true, message: 'Stage assignments and recorded results cleared.' };
|
||||
await clearCs2StageAssignments(eventId);
|
||||
return { success: true, message: 'Stage assignments cleared.' };
|
||||
}
|
||||
|
||||
if (intent === 'assign') {
|
||||
|
|
@ -140,10 +126,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
await markCs2StageEliminations(eventId, eliminations);
|
||||
await assignCs2EliminationQP(eventId, params.id);
|
||||
// Propagate the derived QP/placements to every sibling window (in-progress:
|
||||
// don't mark complete — that happens when the bracket is finalized).
|
||||
const event = await getScoringEventById(eventId);
|
||||
if (event) await fanOutMajorIfPrimary(event, { markComplete: false });
|
||||
return { success: true, message: `Marked ${eliminations.length} eliminations.` };
|
||||
} catch (err) {
|
||||
return { success: false, message: err instanceof Error ? err.message : 'Failed to save eliminations.' };
|
||||
|
|
@ -286,14 +268,7 @@ export default function AdminCs2Setup() {
|
|||
<CardTitle className="flex items-center justify-between">
|
||||
Stage Assignments
|
||||
{stageResults.length > 0 && (
|
||||
<Form
|
||||
method="post"
|
||||
onSubmit={(e) => {
|
||||
if (!confirm('Reset this event? This clears all stage assignments AND recorded results/QP for the event so you can set it up again. The Champions Stage bracket is kept.')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="reset" />
|
||||
<Button type="submit" variant="ghost" size="sm" className="text-destructive">
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
getScoringEventById,
|
||||
completeScoringEvent,
|
||||
updateScoringEvent,
|
||||
isReadOnlySibling,
|
||||
} from "~/models/scoring-event";
|
||||
import {
|
||||
getEventResults,
|
||||
|
|
@ -78,9 +77,6 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
)
|
||||
: new Set();
|
||||
|
||||
// A tournament-linked event that is NOT the primary is a read-only mirror:
|
||||
// scoring happens once on the canonical tournament (or its primary window) and
|
||||
// fans out here. Surface this so the UI can point the admin to the right place.
|
||||
return {
|
||||
sportsSeason: sportsSeason as typeof sportsSeason & {
|
||||
sport: { id: string; name: string; type: string; slug: string; simulatorType: string | null };
|
||||
|
|
@ -93,45 +89,13 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
qpConfig,
|
||||
qpStandings,
|
||||
notParticipatingIds,
|
||||
isReadOnlySibling: isReadOnlySibling(event),
|
||||
canonicalTournamentId: event.tournamentId,
|
||||
};
|
||||
}
|
||||
|
||||
// Scoring intents that mutate this window's results directly. For a read-only
|
||||
// sibling (tournament-linked, non-primary) these are rejected — score on the
|
||||
// canonical tournament page instead, and the result fans out automatically.
|
||||
//
|
||||
// mark/unmark-not-participating are deliberately NOT here: they are per-window
|
||||
// simulator inputs (exclude a participant from this window's EV draws), have no
|
||||
// canonical-tournament equivalent, and must stay editable on every window.
|
||||
const SCORING_INTENTS = new Set([
|
||||
"process-qp",
|
||||
"complete",
|
||||
"uncomplete",
|
||||
"add-result",
|
||||
"update-result",
|
||||
"delete-result",
|
||||
"update-standings",
|
||||
"batch-add-results",
|
||||
]);
|
||||
|
||||
export async function action({ request, params }: Route.ActionArgs) {
|
||||
const formData = await request.formData();
|
||||
const intent = formData.get("intent");
|
||||
|
||||
// Enforce single-source scoring: reject direct scoring on a read-only sibling.
|
||||
if (typeof intent === "string" && SCORING_INTENTS.has(intent)) {
|
||||
const ev = await getScoringEventById(params.eventId);
|
||||
if (ev && isReadOnlySibling(ev)) {
|
||||
return {
|
||||
error:
|
||||
"This event belongs to a shared major. Score it once on the tournament page (Admin → Tournaments) and it fans out to every window automatically.",
|
||||
canonicalTournamentId: ev.tournamentId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === "mark-qualifying") {
|
||||
try {
|
||||
const event = await getScoringEventById(params.eventId);
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
} from "~/components/ui/select";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { ArrowLeft, Trophy, CheckCircle2, Pencil, Trash2, Brackets, Save } from "lucide-react";
|
||||
import { getEventTypeLabel } from "~/models/scoring-event-types";
|
||||
import { getEventTypeLabel } from "~/models/scoring-event";
|
||||
import { isBracketMajor } from "~/lib/event-utils";
|
||||
import {
|
||||
Table,
|
||||
|
|
@ -95,24 +95,9 @@ export default function EventResults({
|
|||
loaderData,
|
||||
actionData,
|
||||
}: Route.ComponentProps) {
|
||||
const { sportsSeason, event, participants, results, participantResults, seasonResults, notParticipatingIds, isReadOnlySibling, canonicalTournamentId } = loaderData;
|
||||
const { sportsSeason, event, participants, results, participantResults, seasonResults, notParticipatingIds } = loaderData;
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// Banner shown on read-only sibling events: scoring lives on the canonical
|
||||
// tournament page, and results fan out here automatically.
|
||||
const readOnlySiblingBanner = isReadOnlySibling ? (
|
||||
<div className="bg-amber-500/15 text-amber-500 border border-amber-500/30 px-4 py-3 rounded-md text-sm mb-4">
|
||||
This event is part of a shared major. Scoring is done once on the{" "}
|
||||
<Link
|
||||
to={`/admin/tournaments/${canonicalTournamentId}`}
|
||||
className="underline font-medium"
|
||||
>
|
||||
tournament page
|
||||
</Link>{" "}
|
||||
and fans out to every window automatically — the controls here are read-only.
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const isBracketEvent = event.eventType === "playoff_game" || isBracketMajor(sportsSeason.sport?.simulatorType);
|
||||
|
||||
// Create a map of participants with results for easy lookup
|
||||
|
|
@ -233,7 +218,6 @@ export default function EventResults({
|
|||
return (
|
||||
<div className="p-8">
|
||||
<div className="max-w-4xl">
|
||||
{readOnlySiblingBanner}
|
||||
<div className="mb-6">
|
||||
<Button variant="ghost" size="sm" asChild className="mb-2">
|
||||
<Link to={`/admin/sports-seasons/${sportsSeason.id}/events`}>
|
||||
|
|
|
|||
|
|
@ -7,12 +7,8 @@ import {
|
|||
createScoringEvent,
|
||||
deleteScoringEvent,
|
||||
bulkCreateScoringEvents,
|
||||
ensurePrimaryEvent,
|
||||
countWindowsByTournament,
|
||||
getMajorsCompleted,
|
||||
type CreateScoringEventData,
|
||||
} from "~/models/scoring-event";
|
||||
import { isBracketMajor } from "~/lib/event-utils";
|
||||
import { getQPStandings } from "~/models/qualifying-points";
|
||||
import { finalizeQualifyingPoints } from "~/models/scoring-calculator";
|
||||
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
|
||||
|
|
@ -29,14 +25,10 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
|
||||
const events = await getScoringEventsForSportsSeason(params.id);
|
||||
|
||||
// For qualifying sports seasons, get QP standings with global ranks attached.
|
||||
// majorsCompleted is derived on read (count of completed qualifying events), not a
|
||||
// stored counter — see getMajorsCompleted.
|
||||
// For qualifying sports seasons, get QP standings with global ranks attached
|
||||
let qpStandings = null;
|
||||
const scoringRules = null;
|
||||
let majorsCompleted = 0;
|
||||
if (sportsSeason.scoringPattern === "qualifying_points") {
|
||||
majorsCompleted = await getMajorsCompleted(params.id);
|
||||
const standings = await getQPStandings(params.id);
|
||||
let prevQP = -1;
|
||||
let prevRankStart = 1;
|
||||
|
|
@ -65,38 +57,11 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
);
|
||||
const availableTournaments = allTournamentsForSport.filter((t) => !linkedTournamentIds.has(t.id));
|
||||
|
||||
// Shared-tournament context for the delete confirmation dialog: for each event
|
||||
// linked to a canonical tournament, how many OTHER seasons ("windows") also use
|
||||
// it, and the tournament's name. Lets the UI explain exactly what a delete does.
|
||||
const sharedInfo = new Map<string, { name: string; windowCount: number }>();
|
||||
await Promise.all(
|
||||
[...linkedTournamentIds].map(async (tid) => {
|
||||
const cached = allTournamentsForSport.find((t) => t.id === tid);
|
||||
const [tournament, windowCount] = await Promise.all([
|
||||
cached ?? getTournamentById(tid),
|
||||
countWindowsByTournament(tid),
|
||||
]);
|
||||
sharedInfo.set(tid, {
|
||||
name: tournament?.name ?? "shared tournament",
|
||||
windowCount,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const eventsWithSharing = events.map((e) => {
|
||||
const info = e.tournamentId ? sharedInfo.get(e.tournamentId) : null;
|
||||
return {
|
||||
...e,
|
||||
tournamentName: info?.name ?? null,
|
||||
otherWindowCount: info ? Math.max(0, info.windowCount - 1) : 0,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
sportsSeason: { ...sportsSeason, majorsCompleted } as typeof sportsSeason & {
|
||||
sportsSeason: sportsSeason as typeof sportsSeason & {
|
||||
sport: { id: string; name: string; type: string; slug: string };
|
||||
},
|
||||
events: eventsWithSharing,
|
||||
events,
|
||||
qpStandings,
|
||||
scoringRules,
|
||||
availableTournaments,
|
||||
|
|
@ -127,12 +92,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
eventDate,
|
||||
eventStartsAt: tournament.startsAt ? new Date(tournament.startsAt) : undefined,
|
||||
});
|
||||
// Bracket majors (tennis/CS2) need a primary window to be scorable. Seed it
|
||||
// on the first linked window; later windows stay siblings (idempotent).
|
||||
const ss = await findSportsSeasonById(params.id);
|
||||
if (isBracketMajor(ss?.sport?.simulatorType)) {
|
||||
await ensurePrimaryEvent(tournament.id, event.id);
|
||||
}
|
||||
return redirect(`/admin/sports-seasons/${params.id}/events/${event.id}`);
|
||||
} catch (error) {
|
||||
logger.error("Error adding scoring event from tournament:", error);
|
||||
|
|
@ -226,13 +185,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
|
||||
try {
|
||||
const created = await bulkCreateScoringEvents(params.id, validEvents);
|
||||
// Seed a primary window per bracket-major tournament that lacks one.
|
||||
if (isBracketMajor(sportsSeason.sport?.simulatorType)) {
|
||||
for (const ev of created) {
|
||||
if (ev.tournamentId) await ensurePrimaryEvent(ev.tournamentId, ev.id);
|
||||
}
|
||||
}
|
||||
await bulkCreateScoringEvents(params.id, validEvents);
|
||||
return { success: `Created ${validEvents.length} event${validEvents.length !== 1 ? "s" : ""} successfully` };
|
||||
} catch (error) {
|
||||
logger.error("Error bulk creating events:", error);
|
||||
|
|
@ -248,19 +201,8 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
}
|
||||
|
||||
try {
|
||||
const result = await deleteScoringEvent(eventId, undefined, {
|
||||
deleteOrphanTournament: formData.get("deleteTournament") === "1",
|
||||
});
|
||||
let success = "Event deleted";
|
||||
if (result.deletedTournament) {
|
||||
success = "Event deleted and its shared tournament removed";
|
||||
} else if (result.tournamentId && result.remainingWindows > 0) {
|
||||
success = `Event removed from this season (shared tournament kept — still used by ${result.remainingWindows} season${result.remainingWindows !== 1 ? "s" : ""})`;
|
||||
if (result.promotedPrimaryId) {
|
||||
success += "; another season was promoted to primary";
|
||||
}
|
||||
}
|
||||
return { success };
|
||||
await deleteScoringEvent(eventId);
|
||||
return { success: "Event deleted successfully" };
|
||||
} catch (error) {
|
||||
logger.error("Error deleting event:", error);
|
||||
return { error: "Failed to delete event" };
|
||||
|
|
@ -340,11 +282,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
|
||||
try {
|
||||
const event = await createScoringEvent(eventData);
|
||||
// Bracket majors need a primary window to be scorable — seed it on the first
|
||||
// linked window (idempotent for later windows).
|
||||
if (eventData.tournamentId && isBracketMajor(sportsSeason.sport?.simulatorType)) {
|
||||
await ensurePrimaryEvent(eventData.tournamentId, event.id);
|
||||
}
|
||||
return redirect(`/admin/sports-seasons/${params.id}/events/${event.id}`);
|
||||
} catch (error) {
|
||||
logger.error("Error creating scoring event:", error);
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import {
|
|||
import { Calendar, Trophy, ArrowLeft, Trash2, ListPlus } from "lucide-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { QualifyingPointsStandings } from "~/components/scoring/QualifyingPointsStandings";
|
||||
import { getEventTypeLabel } from "~/models/scoring-event-types";
|
||||
import { getEventTypeLabel } from "~/models/scoring-event";
|
||||
|
||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||
return [{ title: `Events — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
||||
|
|
@ -305,11 +305,7 @@ export default function SportsSeasonEvents({
|
|||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{events.map((event: { id: string; name: string; eventType: string; eventDate?: string | null; eventStartsAt?: Date | string | null; isComplete: boolean; tournamentId: string | null; isPrimary: boolean; tournamentName: string | null; otherWindowCount: number }) => {
|
||||
const isLinked = !!event.tournamentId;
|
||||
const hasOtherWindows = event.otherWindowCount > 0;
|
||||
const isLastWindow = isLinked && !hasOtherWindows;
|
||||
return (
|
||||
{events.map((event: { id: string; name: string; eventType: string; eventDate?: string | null; eventStartsAt?: Date | string | null; isComplete: boolean }) => (
|
||||
<Card key={event.id} className="hover:border-primary/50 transition-colors">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
|
|
@ -356,65 +352,28 @@ export default function SportsSeasonEvents({
|
|||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="delete-event" />
|
||||
<input type="hidden" name="eventId" value={event.id} />
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete "{event.name}"?</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
{!isLinked ? (
|
||||
<span>
|
||||
This will also delete all results for this event. This action cannot be undone.
|
||||
</span>
|
||||
) : hasOtherWindows ? (
|
||||
<span>
|
||||
This event is part of the shared tournament{" "}
|
||||
<span className="font-medium">"{event.tournamentName}"</span>, also used by{" "}
|
||||
<span className="font-medium">
|
||||
{event.otherWindowCount} other season{event.otherWindowCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
. Deleting removes it from <span className="font-medium">this season only</span> — the
|
||||
tournament and the other seasons keep their data.
|
||||
{event.isPrimary && (
|
||||
<> This is the primary scoring window, so another season will be promoted to primary automatically.</>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
This is the only season linked to the shared tournament{" "}
|
||||
<span className="font-medium">"{event.tournamentName}"</span>. Deleting removes this
|
||||
event and its results. This action cannot be undone.
|
||||
</span>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{isLastWindow && (
|
||||
<label className="flex items-start gap-2 text-sm my-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="deleteTournament"
|
||||
value="1"
|
||||
className="mt-0.5 h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<span>
|
||||
Also delete the shared tournament and its recorded results
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete "{event.name}"?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will also delete all results for this event. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<Form method="post">
|
||||
<input type="hidden" name="intent" value="delete-event" />
|
||||
<input type="hidden" name="eventId" value={event.id} />
|
||||
<AlertDialogAction type="submit" className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</Form>
|
||||
</Form>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue