Compare commits
99 commits
claude/cha
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8006d514a4 | |||
|
|
ed9c62571b | ||
|
|
41a0237a87 | ||
| 2639102f06 | |||
|
|
caff5093ae | ||
|
|
cdf86d1682 | ||
| 10363da100 | |||
|
|
95acc6fcba | ||
|
|
4e48a23f6b | ||
| e7c5b38953 | |||
|
|
a7b7921b9b | ||
|
|
a747d73a4c | ||
|
|
273735e572 | ||
|
|
b566c5f1a5 | ||
| f3e00fcacf | |||
|
|
101e102e23 | ||
|
|
280a46eb5f | ||
| 758166dd46 | |||
|
|
d83f6976bf | ||
|
|
eefe407e7f | ||
|
|
cf817cc9ff | ||
| 1acef25027 | |||
|
|
1e215c3ac9 | ||
|
|
7ec17e417d | ||
| 3d89db3d0a | |||
|
|
21422a7e92 | ||
|
|
ea58db9595 | ||
| 3c7272392e | |||
|
|
3fae78c521 | ||
|
|
d4df0b65fb | ||
| 2356e37163 | |||
|
|
569081fe29 | ||
|
|
89ceee432a | ||
| 8edb4293c5 | |||
| 81d813d3f3 | |||
|
|
37af2f1fc3 | ||
|
|
1bd23a4419 | ||
|
|
b85f387c79 | ||
|
|
932f36ca07 | ||
| 7e578c714c | |||
|
|
687fb6f040 | ||
|
|
5d0363a309 | ||
| b05bad6554 | |||
|
|
cab161424e | ||
|
|
3f16d5e1d3 | ||
| 036411e9d8 | |||
|
|
07f1356cd3 | ||
|
|
3f6af2665e | ||
| 91227b2cb3 | |||
|
|
b9a9eb4c4b | ||
|
|
7ca89aafc4 | ||
| 8416b54052 | |||
|
|
698c6a6509 | ||
|
|
12c165a481 | ||
|
|
0baef5a948 | ||
| 58e656f15e | |||
| 8a948e1388 | |||
|
|
5be8ac3f2f | ||
|
|
8900bf79dc | ||
| 0c100aa834 | |||
|
|
ec89e3eb89 | ||
|
|
7f7ea4e29d | ||
| 7cbcc6fb14 | |||
|
|
77963a13f4 | ||
|
|
7f021aa534 | ||
| f527fc8f7f | |||
|
|
ef7a71485c | ||
| 6b0e32c3d5 | |||
| b0c25beb90 | |||
| 64e5cb23ef | |||
| 3e50619629 | |||
| 8efa4aab9e | |||
| 3273cf1bcb | |||
| 070b825077 | |||
| e3e8485e26 | |||
| b8c21d227b | |||
| dfbb25771d | |||
|
|
81d063faa1 | ||
| 120056b0bd | |||
| ee099c64cd | |||
| ab74b19420 | |||
|
|
88248e349c | ||
|
|
6772079e86 | ||
|
|
9480501932 | ||
|
|
b960d39be3 | ||
| 4a5ea6fdc5 | |||
|
|
dfbcb7d953 | ||
| 472875f151 | |||
| dedd2f3d86 | |||
| f078eab492 | |||
| d31c23d63b | |||
| d9ea815f50 | |||
| 496ecba464 | |||
|
|
fa98d514f2 | ||
|
|
9a04e25c20 | ||
|
|
363413cd38 | ||
| 08ae2bb88a | |||
| 8c9d328301 | |||
| 7da8be9978 |
201 changed files with 40594 additions and 3637 deletions
|
|
@ -9,17 +9,14 @@
|
||||||
"command": ".claude/hooks/lint-on-edit.sh",
|
"command": ".claude/hooks/lint-on-edit.sh",
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
"statusMessage": "Linting..."
|
"statusMessage": "Linting..."
|
||||||
}
|
},
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"Stop": [
|
|
||||||
{
|
|
||||||
"hooks": [
|
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "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",
|
"if": "Write(*.ts)|Write(*.tsx)|Edit(*.ts)|Edit(*.tsx)|MultiEdit(*.ts)|MultiEdit(*.tsx)",
|
||||||
"timeout": 60
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,3 +31,8 @@ CLOUDINARY_API_SECRET=""
|
||||||
# Must match the CRON_SECRET repo secret in Forgejo.
|
# Must match the CRON_SECRET repo secret in Forgejo.
|
||||||
# Generate with: openssl rand -hex 32
|
# Generate with: openssl rand -hex 32
|
||||||
CRON_SECRET=""
|
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", () => {
|
describe("QualifyingPointsStandings", () => {
|
||||||
it("renders fractional QP to hundredths", () => {
|
it("renders fractional QP to at most hundredths with trailing zeros trimmed", () => {
|
||||||
render(
|
render(
|
||||||
<QualifyingPointsStandings
|
<QualifyingPointsStandings
|
||||||
standings={[
|
standings={[
|
||||||
|
|
@ -52,7 +52,8 @@ describe("QualifyingPointsStandings", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("14.67 QP")).toBeInTheDocument();
|
expect(screen.getByText("14.67 QP")).toBeInTheDocument();
|
||||||
expect(screen.getByText("0.50 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.43 QP")).toBeInTheDocument();
|
expect(screen.getByText("0.43 QP")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
236
app/components/admin/DraftScheduleGantt.tsx
Normal file
236
app/components/admin/DraftScheduleGantt.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
app/components/admin/__tests__/DraftScheduleGantt.test.tsx
Normal file
97
app/components/admin/__tests__/DraftScheduleGantt.test.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
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,20 +118,23 @@ function ActiveRow({
|
||||||
</div>
|
</div>
|
||||||
{/* Stats — second row on mobile, right side on desktop */}
|
{/* Stats — second row on mobile, right side on desktop */}
|
||||||
{showStats && (
|
{showStats && (
|
||||||
<div className="flex items-center gap-4 border-t border-border/50 pt-2 sm:border-0 sm:pt-0 sm:shrink-0">
|
<div className="flex items-start gap-4 border-t border-border/50 pt-2 sm:border-0 sm:pt-0 sm:shrink-0">
|
||||||
{displayRank !== undefined && (
|
{displayRank !== undefined && (
|
||||||
<RankingDisplay
|
<RankingDisplay
|
||||||
displayRank={displayRank}
|
displayRank={displayRank}
|
||||||
rankChange={previousRank !== undefined && currentRank !== undefined && previousRank !== currentRank ? previousRank - currentRank : undefined}
|
rankChange={previousRank !== undefined && currentRank !== undefined && previousRank !== currentRank ? previousRank - currentRank : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{currentRank !== undefined && totalPoints !== undefined && <StatDivider />}
|
{currentRank !== undefined && totalPoints !== undefined && <StatDivider className="h-8" />}
|
||||||
{totalPoints !== undefined && (
|
{totalPoints !== undefined && (
|
||||||
<StatColumn label="Points">
|
<StatColumn
|
||||||
|
label="Points"
|
||||||
|
value={
|
||||||
<span className="text-2xl font-bold leading-none text-electric">
|
<span className="text-2xl font-bold leading-none text-electric">
|
||||||
{Math.round(totalPoints).toLocaleString("en-US")}
|
{Math.round(totalPoints).toLocaleString("en-US")}
|
||||||
</span>
|
</span>
|
||||||
</StatColumn>
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -169,17 +172,21 @@ function PreDraftRow({
|
||||||
</div>
|
</div>
|
||||||
{/* Stats — second row on mobile, right side on desktop */}
|
{/* 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">
|
<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">
|
<StatColumn
|
||||||
<span className="text-2xl font-bold leading-none">{draftTimeValue}</span>
|
label="Draft"
|
||||||
</StatColumn>
|
value={<span className="text-2xl font-bold leading-none">{draftTimeValue}</span>}
|
||||||
|
/>
|
||||||
{draftPosition !== undefined && (
|
{draftPosition !== undefined && (
|
||||||
<>
|
<>
|
||||||
<StatDivider />
|
<StatDivider className="h-8" />
|
||||||
<StatColumn label="Position">
|
<StatColumn
|
||||||
|
label="Position"
|
||||||
|
value={
|
||||||
<span className="text-2xl font-bold leading-none">
|
<span className="text-2xl font-bold leading-none">
|
||||||
{ordinal(draftPosition)}
|
{ordinal(draftPosition)}
|
||||||
</span>
|
</span>
|
||||||
</StatColumn>
|
}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,52 @@ 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 = {
|
export const WithTies: Story = {
|
||||||
args: {
|
args: {
|
||||||
entries: [
|
entries: [
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,17 @@ import { Card, CardContent, CardHeader } from "~/components/ui/card";
|
||||||
import { GradientIcon } from "~/components/ui/GradientIcon";
|
import { GradientIcon } from "~/components/ui/GradientIcon";
|
||||||
import { TeamAvatar } from "~/components/TeamAvatar";
|
import { TeamAvatar } from "~/components/TeamAvatar";
|
||||||
import type { AvatarData, RawFlagConfig } from "~/lib/flag-types";
|
import type { AvatarData, RawFlagConfig } from "~/lib/flag-types";
|
||||||
import { StatColumn, StatDivider, PointChangeIndicator, RankingDisplay } from "./StatHelpers";
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Row styles ───────────────────────────────────────────────────────────────
|
// ─── Row styles ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -23,7 +33,20 @@ function rowClasses(currentRank: number | undefined, hasHref: boolean): string {
|
||||||
|
|
||||||
// ─── Row content ──────────────────────────────────────────────────────────────
|
// ─── Row content ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function RowContent({ entry }: { entry: StandingsPreviewEntry }) {
|
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);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Left: avatar + name */}
|
{/* Left: avatar + name */}
|
||||||
|
|
@ -46,16 +69,44 @@ function RowContent({ entry }: { entry: StandingsPreviewEntry }) {
|
||||||
|
|
||||||
{/* Right: stats — second row on mobile */}
|
{/* 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">
|
<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} />
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<StatDivider />
|
<StatDivider />
|
||||||
<StatColumn label="Points">
|
<StatColumn
|
||||||
|
label="Points"
|
||||||
|
reserveDelta={rowHasChange}
|
||||||
|
value={
|
||||||
<span className="text-2xl font-bold leading-none text-electric">
|
<span className="text-2xl font-bold leading-none text-electric">
|
||||||
{Math.round(entry.points).toLocaleString("en-US")}
|
{Math.round(entry.points).toLocaleString("en-US")}
|
||||||
</span>
|
</span>
|
||||||
{entry.pointChange !== undefined && entry.pointChange !== 0 && (
|
}
|
||||||
<PointChangeIndicator delta={entry.pointChange} />
|
delta={
|
||||||
)}
|
entry.pointChange !== undefined && entry.pointChange !== 0 ? (
|
||||||
</StatColumn>
|
<DeltaBadge delta={entry.pointChange} kind="points" />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -81,15 +132,26 @@ export interface StandingsPreviewEntry {
|
||||||
rankChange?: number;
|
rankChange?: number;
|
||||||
/** 7-day point delta. From TeamStanding.sevenDayPointChange. */
|
/** 7-day point delta. From TeamStanding.sevenDayPointChange. */
|
||||||
pointChange?: number;
|
pointChange?: number;
|
||||||
|
/** Projected final points. From TeamStanding.projectedPoints. */
|
||||||
|
projectedPoints?: number | null;
|
||||||
|
/** Live participants left; projection only shown while > 0. */
|
||||||
|
participantsRemaining?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StandingsPreviewProps {
|
export interface StandingsPreviewProps {
|
||||||
entries: StandingsPreviewEntry[];
|
entries: StandingsPreviewEntry[];
|
||||||
description?: string;
|
description?: string;
|
||||||
fullStandingsHref?: string;
|
fullStandingsHref?: string;
|
||||||
|
/** When true, adds a "Projected" column (projected final points). Default false. */
|
||||||
|
showProjected?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StandingsPreview({ entries, description, fullStandingsHref }: StandingsPreviewProps) {
|
export function StandingsPreview({
|
||||||
|
entries,
|
||||||
|
description,
|
||||||
|
fullStandingsHref,
|
||||||
|
showProjected = false,
|
||||||
|
}: StandingsPreviewProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="gap-2">
|
<Card className="gap-2">
|
||||||
<CardHeader className="px-3 sm:px-6 pb-2">
|
<CardHeader className="px-3 sm:px-6 pb-2">
|
||||||
|
|
@ -119,11 +181,11 @@ export function StandingsPreview({ entries, description, fullStandingsHref }: St
|
||||||
to={entry.href}
|
to={entry.href}
|
||||||
className={rowClasses(entry.currentRank, true)}
|
className={rowClasses(entry.currentRank, true)}
|
||||||
>
|
>
|
||||||
<RowContent entry={entry} />
|
<RowContent entry={entry} showProjected={showProjected} />
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<div key={entry.teamId} className={rowClasses(entry.currentRank, false)}>
|
<div key={entry.teamId} className={rowClasses(entry.currentRank, false)}>
|
||||||
<RowContent entry={entry} />
|
<RowContent entry={entry} showProjected={showProjected} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,71 @@
|
||||||
|
/**
|
||||||
|
* 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({
|
export function StatColumn({
|
||||||
label,
|
label,
|
||||||
children,
|
value,
|
||||||
|
delta,
|
||||||
|
reserveDelta = false,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
children: React.ReactNode;
|
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;
|
||||||
}) {
|
}) {
|
||||||
|
const showDeltaLine = delta !== undefined || reserveDelta;
|
||||||
return (
|
return (
|
||||||
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-baseline justify-end gap-1">{children}</div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatDivider() {
|
export function StatDivider({ className = "h-12" }: { className?: string } = {}) {
|
||||||
return <div className="h-8 w-px bg-border shrink-0 self-center" />;
|
return <div className={`${className} w-px bg-border shrink-0 self-center`} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RankChangeIndicator({ delta }: { delta: number }) {
|
/**
|
||||||
if (delta === 0) return null;
|
* 7-day change indicator. `rank` deltas are unitless places; `points` deltas are
|
||||||
if (delta > 0) {
|
* 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) {
|
||||||
return (
|
return (
|
||||||
<span className="text-xs font-semibold text-primary" aria-label={`up ${delta}`}>
|
<span className="text-xs font-semibold text-primary" aria-label={label}>
|
||||||
▲{delta}
|
▲{magnitude}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -32,9 +73,9 @@ export function RankChangeIndicator({ delta }: { delta: number }) {
|
||||||
<span
|
<span
|
||||||
className="text-xs font-semibold"
|
className="text-xs font-semibold"
|
||||||
style={{ color: "var(--coral-accent, #ef4444)" }}
|
style={{ color: "var(--coral-accent, #ef4444)" }}
|
||||||
aria-label={`down ${Math.abs(delta)}`}
|
aria-label={label}
|
||||||
>
|
>
|
||||||
▼{Math.abs(delta)}
|
▼{magnitude}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -42,35 +83,22 @@ export function RankChangeIndicator({ delta }: { delta: number }) {
|
||||||
export function RankingDisplay({
|
export function RankingDisplay({
|
||||||
displayRank,
|
displayRank,
|
||||||
rankChange,
|
rankChange,
|
||||||
|
reserveDelta = false,
|
||||||
}: {
|
}: {
|
||||||
displayRank: string | number;
|
displayRank: string | number;
|
||||||
rankChange?: number;
|
rankChange?: number;
|
||||||
|
reserveDelta?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<StatColumn label="Ranking">
|
<StatColumn
|
||||||
<span className="text-2xl font-bold leading-none">{displayRank}</span>
|
label="Ranking"
|
||||||
{rankChange !== undefined && rankChange !== 0 && (
|
reserveDelta={reserveDelta}
|
||||||
<RankChangeIndicator delta={rankChange} />
|
value={<span className="text-2xl font-bold leading-none">{displayRank}</span>}
|
||||||
)}
|
delta={
|
||||||
</StatColumn>
|
rankChange !== undefined && rankChange !== 0 ? (
|
||||||
);
|
<DeltaBadge delta={rankChange} kind="rank" />
|
||||||
}
|
) : undefined
|
||||||
|
}
|
||||||
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,4 +103,54 @@ describe("StandingsPreview", () => {
|
||||||
expect(link).toHaveAttribute("href", "/leagues/1/standings/1");
|
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,6 +1,7 @@
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import type { LucideProps } from "lucide-react";
|
import type { LucideProps } from "lucide-react";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { Link } from "react-router";
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
type SettingsNavSection = {
|
type SettingsNavSection = {
|
||||||
|
|
@ -17,28 +18,20 @@ export type SettingsGridSection = SettingsNavSection & {
|
||||||
export function SettingsMobileGridNav({
|
export function SettingsMobileGridNav({
|
||||||
sections,
|
sections,
|
||||||
onSectionChange,
|
onSectionChange,
|
||||||
|
buildHref,
|
||||||
}: {
|
}: {
|
||||||
sections: readonly SettingsGridSection[];
|
sections: readonly SettingsGridSection[];
|
||||||
onSectionChange: (sectionId: string) => void;
|
onSectionChange?: (sectionId: string) => void;
|
||||||
|
buildHref?: (sectionId: string) => string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
const cardClassName = (section: SettingsGridSection) =>
|
||||||
<div className="mb-5 lg:hidden">
|
cn(
|
||||||
<div className="mb-3">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
||||||
Manage
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
{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]",
|
"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"
|
section.isDanger && "border-destructive/40"
|
||||||
)}
|
);
|
||||||
>
|
|
||||||
|
const cardInner = (section: SettingsGridSection) => (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-10 items-center justify-center rounded-lg",
|
"flex h-10 w-10 items-center justify-center rounded-lg",
|
||||||
|
|
@ -53,8 +46,33 @@ export function SettingsMobileGridNav({
|
||||||
<p className="text-sm font-semibold leading-tight">{section.label}</p>
|
<p className="text-sm font-semibold leading-tight">{section.label}</p>
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">{section.subtitle}</p>
|
<p className="mt-0.5 text-xs text-muted-foreground">{section.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-5 lg:hidden">
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Manage
|
||||||
|
</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)}
|
||||||
|
>
|
||||||
|
{cardInner(section)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -62,19 +80,30 @@ export function SettingsMobileGridNav({
|
||||||
|
|
||||||
export function SettingsMobileSectionPill({
|
export function SettingsMobileSectionPill({
|
||||||
onShowGrid,
|
onShowGrid,
|
||||||
|
backHref,
|
||||||
}: {
|
}: {
|
||||||
onShowGrid: () => void;
|
onShowGrid?: () => void;
|
||||||
|
backHref?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
const className =
|
||||||
<div className="mb-5 lg:hidden">
|
"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";
|
||||||
<button
|
const content = (
|
||||||
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" />
|
<ArrowLeft className="h-3.5 w-3.5" />
|
||||||
Back to all settings
|
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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -83,33 +112,57 @@ export function SettingsDesktopNav({
|
||||||
sections,
|
sections,
|
||||||
activeSection,
|
activeSection,
|
||||||
onSectionChange,
|
onSectionChange,
|
||||||
|
buildHref,
|
||||||
|
navLabel = "League settings",
|
||||||
}: {
|
}: {
|
||||||
sections: readonly SettingsGridSection[];
|
sections: readonly SettingsGridSection[];
|
||||||
activeSection: string;
|
activeSection: string;
|
||||||
onSectionChange: (sectionId: string) => void;
|
onSectionChange?: (sectionId: string) => void;
|
||||||
|
buildHref?: (sectionId: string) => string;
|
||||||
|
navLabel?: string;
|
||||||
}) {
|
}) {
|
||||||
|
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 (
|
return (
|
||||||
<aside className="hidden lg:block">
|
<aside className="hidden lg:block">
|
||||||
<div className="sticky top-6 rounded-xl border bg-card p-3">
|
<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">
|
<p className="px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
Manage
|
Manage
|
||||||
</p>
|
</p>
|
||||||
<nav aria-label="League settings" className="space-y-1">
|
<nav aria-label={navLabel} className="space-y-1">
|
||||||
{sections.map((section) => (
|
{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
|
<button
|
||||||
key={section.id}
|
key={section.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSectionChange(section.id)}
|
onClick={() => onSectionChange?.(section.id)}
|
||||||
aria-current={activeSection === section.id ? "page" : undefined}
|
aria-current={activeSection === section.id ? "page" : undefined}
|
||||||
className={cn(
|
className={itemClassName(section)}
|
||||||
"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" />
|
{itemInner(section)}
|
||||||
{section.label}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
)
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
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,13 +1,16 @@
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { useRoundTransition } from "~/hooks/useRoundTransition";
|
import { useRoundTransition } from "~/hooks/useRoundTransition";
|
||||||
|
import type { FeederMap } from "~/lib/bracket-layout";
|
||||||
|
import type { BracketTemplate } from "~/lib/bracket-templates";
|
||||||
import {
|
import {
|
||||||
TreeColumns,
|
TreeColumns,
|
||||||
BracketMatchSlot,
|
BracketMatchSlot,
|
||||||
|
bracketGeometry,
|
||||||
|
windowGeometry,
|
||||||
SLOT_WIDTH,
|
SLOT_WIDTH,
|
||||||
LABEL_HEIGHT,
|
LABEL_HEIGHT,
|
||||||
DESIRED_CARD_HEIGHT,
|
DESIRED_CARD_HEIGHT,
|
||||||
CARD_GAP,
|
|
||||||
MAX_CARD_HEIGHT,
|
MAX_CARD_HEIGHT,
|
||||||
type BracketMatch,
|
type BracketMatch,
|
||||||
type BracketOwnership,
|
type BracketOwnership,
|
||||||
|
|
@ -21,6 +24,8 @@ interface BracketTreePaginatedProps {
|
||||||
/** Index of the first scoring round — default page starts here */
|
/** Index of the first scoring round — default page starts here */
|
||||||
firstScoringRoundIdx?: number;
|
firstScoringRoundIdx?: number;
|
||||||
thirdPlaceRound?: string;
|
thirdPlaceRound?: string;
|
||||||
|
feeders?: FeederMap;
|
||||||
|
template?: BracketTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BracketTreePaginated({
|
export function BracketTreePaginated({
|
||||||
|
|
@ -30,63 +35,68 @@ export function BracketTreePaginated({
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
firstScoringRoundIdx,
|
firstScoringRoundIdx,
|
||||||
thirdPlaceRound,
|
thirdPlaceRound,
|
||||||
|
feeders,
|
||||||
|
template,
|
||||||
}: BracketTreePaginatedProps) {
|
}: BracketTreePaginatedProps) {
|
||||||
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
||||||
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
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(
|
const defaultPage = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(
|
Math.min(
|
||||||
firstScoringRoundIdx !== undefined
|
firstScoringRoundIdx !== undefined ? Math.max(0, firstScoringRoundIdx - 1) : lastPage,
|
||||||
? Math.max(0, firstScoringRoundIdx - 1)
|
lastPage,
|
||||||
: mainRounds.length - 2,
|
|
||||||
mainRounds.length - 2,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { page, anim, stripRef, navigate, handleTransitionEnd } = useRoundTransition(
|
const { page, anim, stripRef, navigate, handleTransitionEnd } = useRoundTransition(
|
||||||
mainRounds.length - 2,
|
lastPage,
|
||||||
defaultPage,
|
defaultPage,
|
||||||
);
|
);
|
||||||
|
|
||||||
const targetPage = anim ? anim.toPage : page;
|
const pageGeometry = (p: number) => windowGeometry(geometry, p, p + 1);
|
||||||
const labelRounds = mainRounds.slice(targetPage, targetPage + 2);
|
const labelFor = (p: number) => {
|
||||||
const label = labelRounds[1] ? `${labelRounds[0]} → ${labelRounds[1]}` : labelRounds[0];
|
const [a, b] = [columns[p]?.label, columns[p + 1]?.label];
|
||||||
|
return b ? `${a} → ${b}` : (a ?? "");
|
||||||
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 pageHeight = calcHeight(page);
|
const label = labelFor(anim ? anim.toPage : page);
|
||||||
const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight;
|
|
||||||
const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight;
|
|
||||||
|
|
||||||
const visibleRounds = mainRounds.slice(page, page + 2);
|
const pageG = pageGeometry(page);
|
||||||
const fromRounds = anim ? mainRounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds;
|
const animFromG = anim ? pageGeometry(anim.fromPage) : pageG;
|
||||||
const toRounds = anim ? mainRounds.slice(anim.toPage, anim.toPage + 2) : visibleRounds;
|
const animToG = anim ? pageGeometry(anim.toPage) : pageG;
|
||||||
|
|
||||||
let leftRounds: string[];
|
let leftPage: number;
|
||||||
let rightRounds: string[] = [];
|
let rightPage: number | null = null;
|
||||||
let leftHeight: number;
|
let leftG = pageG;
|
||||||
let rightHeight = 0;
|
let rightG = pageG;
|
||||||
let settlingTransition = false;
|
let settlingTransition = false;
|
||||||
if (anim?.phase === "sliding") {
|
if (anim?.phase === "sliding") {
|
||||||
leftRounds = anim.dir === "right" ? fromRounds : toRounds;
|
leftPage = anim.dir === "right" ? anim.fromPage : anim.toPage;
|
||||||
rightRounds = anim.dir === "right" ? toRounds : fromRounds;
|
rightPage = anim.dir === "right" ? anim.toPage : anim.fromPage;
|
||||||
leftHeight = anim.dir === "right" ? animFromHeight : animToHeight;
|
leftG = anim.dir === "right" ? animFromG : animToG;
|
||||||
rightHeight = anim.dir === "right" ? animToHeight : animFromHeight;
|
rightG = anim.dir === "right" ? animToG : animFromG;
|
||||||
} else if (anim?.phase === "settling") {
|
} else if (anim?.phase === "settling") {
|
||||||
leftRounds = toRounds;
|
leftPage = anim.toPage;
|
||||||
leftHeight = animToHeight;
|
leftG = animToG;
|
||||||
settlingTransition = true;
|
settlingTransition = true;
|
||||||
} else {
|
} else {
|
||||||
leftRounds = visibleRounds;
|
leftPage = page;
|
||||||
leftHeight = pageHeight;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const containerMinHeight = anim?.phase === "settling" ? animToHeight : animFromHeight;
|
const containerMinHeight =
|
||||||
|
anim?.phase === "settling" ? animToG.bracketHeight : animFromG.bracketHeight;
|
||||||
const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0;
|
const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -109,7 +119,7 @@ export function BracketTreePaginated({
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => navigate(page + 1)}
|
onClick={() => navigate(page + 1)}
|
||||||
disabled={page + 2 >= mainRounds.length || !!anim}
|
disabled={page >= lastPage || !!anim}
|
||||||
className="h-7 w-7 shrink-0"
|
className="h-7 w-7 shrink-0"
|
||||||
aria-label="Next rounds"
|
aria-label="Next rounds"
|
||||||
>
|
>
|
||||||
|
|
@ -129,22 +139,24 @@ export function BracketTreePaginated({
|
||||||
>
|
>
|
||||||
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
visibleRounds={leftRounds}
|
geometry={leftG}
|
||||||
matchesByRound={matchesByRound}
|
columnRange={[leftPage, leftPage + 1]}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
bracketHeight={leftHeight}
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
transitionDuration={settlingTransition ? 500 : undefined}
|
transitionDuration={settlingTransition ? 500 : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{anim?.phase === "sliding" && (
|
{anim?.phase === "sliding" && rightPage !== null && (
|
||||||
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
visibleRounds={rightRounds}
|
geometry={rightG}
|
||||||
matchesByRound={matchesByRound}
|
columnRange={[rightPage, rightPage + 1]}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
bracketHeight={rightHeight}
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -164,6 +176,8 @@ export function BracketTreePaginated({
|
||||||
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import { avatarColor } from "~/lib/avatar-colors";
|
import { avatarColor } from "~/lib/avatar-colors";
|
||||||
import { BRACKT_GRADIENT } from "~/lib/brand";
|
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 {
|
export interface BracketMatch {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -46,6 +54,8 @@ function formatScore(score: string | null): string | null {
|
||||||
|
|
||||||
interface ParticipantRowProps {
|
interface ParticipantRowProps {
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
/** What fills this slot when it's still empty, e.g. "Winner of Winners SF 2". */
|
||||||
|
feedLabel?: string | null;
|
||||||
isTbd: boolean;
|
isTbd: boolean;
|
||||||
isWinner: boolean;
|
isWinner: boolean;
|
||||||
isLoser: boolean;
|
isLoser: boolean;
|
||||||
|
|
@ -60,6 +70,7 @@ interface ParticipantRowProps {
|
||||||
|
|
||||||
function ParticipantRow({
|
function ParticipantRow({
|
||||||
name,
|
name,
|
||||||
|
feedLabel,
|
||||||
isTbd,
|
isTbd,
|
||||||
isWinner,
|
isWinner,
|
||||||
isLoser,
|
isLoser,
|
||||||
|
|
@ -114,7 +125,7 @@ function ParticipantRow({
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")}
|
.join(" ")}
|
||||||
>
|
>
|
||||||
{name ?? "TBD"}
|
{name ?? feedLabel ?? "TBD"}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Owner name below participant name */}
|
{/* Owner name below participant name */}
|
||||||
|
|
@ -150,6 +161,8 @@ interface BracketMatchSlotProps {
|
||||||
slotHeight: number;
|
slotHeight: number;
|
||||||
ownershipMap: Map<string, BracketOwnership>;
|
ownershipMap: Map<string, BracketOwnership>;
|
||||||
userParticipantIds: Set<string>;
|
userParticipantIds: Set<string>;
|
||||||
|
feeders?: FeederMap;
|
||||||
|
template?: BracketTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BracketMatchSlot({
|
export function BracketMatchSlot({
|
||||||
|
|
@ -157,6 +170,8 @@ export function BracketMatchSlot({
|
||||||
slotHeight,
|
slotHeight,
|
||||||
ownershipMap,
|
ownershipMap,
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
|
feeders,
|
||||||
|
template,
|
||||||
}: BracketMatchSlotProps) {
|
}: BracketMatchSlotProps) {
|
||||||
const rowHeight = slotHeight / 2;
|
const rowHeight = slotHeight / 2;
|
||||||
const showText = rowHeight >= 10;
|
const showText = rowHeight >= 10;
|
||||||
|
|
@ -187,6 +202,13 @@ export function BracketMatchSlot({
|
||||||
|
|
||||||
const INSET = Math.max(1, Math.min(2, Math.floor(slotHeight / 20)));
|
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 (
|
return (
|
||||||
<div className="relative overflow-hidden" style={{ height: slotHeight }}>
|
<div className="relative overflow-hidden" style={{ height: slotHeight }}>
|
||||||
{/* Corona layer — left-2 matches card borderRadius to prevent gradient bleed at left corners */}
|
{/* Corona layer — left-2 matches card borderRadius to prevent gradient bleed at left corners */}
|
||||||
|
|
@ -208,6 +230,7 @@ export function BracketMatchSlot({
|
||||||
>
|
>
|
||||||
<ParticipantRow
|
<ParticipantRow
|
||||||
name={match.participant1?.name ?? null}
|
name={match.participant1?.name ?? null}
|
||||||
|
feedLabel={feed1}
|
||||||
isTbd={isTbd1}
|
isTbd={isTbd1}
|
||||||
isWinner={p1IsWinner}
|
isWinner={p1IsWinner}
|
||||||
isLoser={p1IsLoser}
|
isLoser={p1IsLoser}
|
||||||
|
|
@ -221,6 +244,7 @@ export function BracketMatchSlot({
|
||||||
/>
|
/>
|
||||||
<ParticipantRow
|
<ParticipantRow
|
||||||
name={match.participant2?.name ?? null}
|
name={match.participant2?.name ?? null}
|
||||||
|
feedLabel={feed2}
|
||||||
isTbd={isTbd2}
|
isTbd={isTbd2}
|
||||||
isWinner={p2IsWinner}
|
isWinner={p2IsWinner}
|
||||||
isLoser={p2IsLoser}
|
isLoser={p2IsLoser}
|
||||||
|
|
@ -237,54 +261,45 @@ export function BracketMatchSlot({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Per-pair connector column ────────────────────────────────────────────────
|
// ─── Connector column ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface ConnectorColumnProps {
|
interface ConnectorColumnProps {
|
||||||
currentMatches: BracketMatch[];
|
/** Edges crossing this gutter, in slot units. */
|
||||||
nextMatches: BracketMatch[];
|
edges: { fromCenter: number; toCenter: number }[];
|
||||||
|
rowHeight: number;
|
||||||
|
offset: number;
|
||||||
bracketHeight: number;
|
bracketHeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: ConnectorColumnProps) {
|
/**
|
||||||
|
* 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) {
|
||||||
const mid = CONNECTOR_WIDTH / 2;
|
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[] = [];
|
const paths: string[] = [];
|
||||||
|
for (const [toCenter, sources] of byTarget) {
|
||||||
const currentSlotH = bracketHeight / Math.max(currentMatches.length, 1);
|
const destY = toCenter * rowHeight - offset;
|
||||||
const nextSlotH = bracketHeight / Math.max(nextMatches.length, 1);
|
const ys = sources.map((c) => c * rowHeight - offset).toSorted((a, b) => a - b);
|
||||||
|
if (ys.length === 1) {
|
||||||
// Use halving U-shapes only when prev > 1 (avoids false-positive 1→1 side branches like 3PG→Finals)
|
paths.push(`M 0 ${ys[0]} H ${mid} V ${destY} H ${CONNECTOR_WIDTH}`);
|
||||||
if (nextMatches.length === Math.ceil(currentMatches.length / 2) && currentMatches.length > 1) {
|
continue;
|
||||||
// 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`);
|
||||||
} else {
|
for (const y of ys.slice(1, -1)) paths.push(`M 0 ${y} H ${mid}`);
|
||||||
// Non-standard (byes, play-ins, etc.): trace winners by participantId
|
paths.push(`M ${mid} ${destY} H ${CONNECTOR_WIDTH}`);
|
||||||
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 (
|
return (
|
||||||
|
|
@ -310,52 +325,131 @@ function ConnectorColumn({ currentMatches, nextMatches, bracketHeight }: Connect
|
||||||
|
|
||||||
// ─── Tree columns (shared by full + paginated) ───────────────────────────────
|
// ─── 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 {
|
interface TreeColumnsProps {
|
||||||
visibleRounds: string[];
|
geometry: BracketGeometry;
|
||||||
matchesByRound: Map<string, BracketMatch[]>;
|
|
||||||
ownershipMap: Map<string, BracketOwnership>;
|
ownershipMap: Map<string, BracketOwnership>;
|
||||||
userParticipantIds: Set<string>;
|
userParticipantIds: Set<string>;
|
||||||
bracketHeight: number;
|
|
||||||
transitionDuration?: 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({
|
export function TreeColumns({
|
||||||
visibleRounds,
|
geometry,
|
||||||
matchesByRound,
|
|
||||||
ownershipMap,
|
ownershipMap,
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
bracketHeight,
|
|
||||||
transitionDuration,
|
transitionDuration,
|
||||||
|
feeders,
|
||||||
|
template,
|
||||||
|
columnRange,
|
||||||
}: TreeColumnsProps) {
|
}: TreeColumnsProps) {
|
||||||
const tr = transitionDuration ? `${transitionDuration}ms ease` : undefined;
|
const tr = transitionDuration ? `${transitionDuration}ms ease` : undefined;
|
||||||
return (
|
const { layout, rowHeight, bracketHeight, offset } = geometry;
|
||||||
<div style={{ display: "flex", width: "100%", height: bracketHeight + LABEL_HEIGHT, transition: tr ? `height ${tr}` : undefined }}>
|
|
||||||
{visibleRounds.map((round, ri) => {
|
const [firstColumn, lastColumn] = columnRange ?? [0, layout.columns.length - 1];
|
||||||
const roundMatches = matchesByRound.get(round) ?? [];
|
const visible = layout.columns.slice(firstColumn, lastColumn + 1);
|
||||||
const slotHeight = bracketHeight / Math.max(roundMatches.length, 1);
|
|
||||||
const cardHeight = Math.min(slotHeight - CARD_GAP, MAX_CARD_HEIGHT);
|
// Cards keep a fixed height regardless of how many share a column — stretching a
|
||||||
const cardTop = (slotHeight - cardHeight) / 2;
|
// lone final to fill its column is what made it tower over the rest of the bracket.
|
||||||
const nextRound = ri < visibleRounds.length - 1 ? visibleRounds[ri + 1] : null;
|
const cardHeight = Math.min(
|
||||||
const nextMatches = nextRound ? (matchesByRound.get(nextRound) ?? []) : [];
|
Math.max(rowHeight - CARD_GAP, 1),
|
||||||
|
MAX_CARD_HEIGHT
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={round} style={{ display: "contents" }}>
|
<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);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={column.label + ci} style={{ display: "contents" }}>
|
||||||
{/* Round column */}
|
{/* Round column */}
|
||||||
<div style={{ flex: "1 1 0", minWidth: COLUMN_WIDTH, position: "relative" }}>
|
<div style={{ flex: "1 1 0", minWidth: COLUMN_WIDTH, position: "relative" }}>
|
||||||
<div
|
<div
|
||||||
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate text-center"
|
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate text-center"
|
||||||
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
|
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||||
>
|
>
|
||||||
{round}
|
{column.label}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ position: "relative", height: bracketHeight, transition: tr ? `height ${tr}` : undefined }}>
|
<div style={{ position: "relative", height: bracketHeight, transition: tr ? `height ${tr}` : undefined }}>
|
||||||
{roundMatches.map((match, matchIdx) => (
|
{column.matches.map(({ match, center }) => (
|
||||||
<div
|
<div
|
||||||
key={match.id}
|
key={match.id}
|
||||||
|
data-match-id={match.id}
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: matchIdx * slotHeight + cardTop,
|
top: center * rowHeight - offset - cardHeight / 2,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
height: cardHeight,
|
height: cardHeight,
|
||||||
|
|
@ -367,6 +461,8 @@ export function TreeColumns({
|
||||||
slotHeight={cardHeight}
|
slotHeight={cardHeight}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
@ -374,10 +470,11 @@ export function TreeColumns({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Connector between this column and the next */}
|
{/* Connector between this column and the next */}
|
||||||
{nextRound && (
|
{vi < visible.length - 1 && (
|
||||||
<ConnectorColumn
|
<ConnectorColumn
|
||||||
currentMatches={roundMatches}
|
edges={gutterEdges}
|
||||||
nextMatches={nextMatches}
|
rowHeight={rowHeight}
|
||||||
|
offset={offset}
|
||||||
bracketHeight={bracketHeight}
|
bracketHeight={bracketHeight}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -396,6 +493,8 @@ interface BracketTreeViewProps {
|
||||||
ownershipMap: Map<string, BracketOwnership>;
|
ownershipMap: Map<string, BracketOwnership>;
|
||||||
userParticipantIds: Set<string>;
|
userParticipantIds: Set<string>;
|
||||||
thirdPlaceRound?: string;
|
thirdPlaceRound?: string;
|
||||||
|
feeders?: FeederMap;
|
||||||
|
template?: BracketTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BracketTreeView({
|
export function BracketTreeView({
|
||||||
|
|
@ -404,13 +503,19 @@ export function BracketTreeView({
|
||||||
ownershipMap,
|
ownershipMap,
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
thirdPlaceRound,
|
thirdPlaceRound,
|
||||||
|
feeders,
|
||||||
|
template,
|
||||||
}: BracketTreeViewProps) {
|
}: BracketTreeViewProps) {
|
||||||
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
||||||
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
||||||
|
|
||||||
const maxMatches = Math.max(...mainRounds.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
const geometry = bracketGeometry(
|
||||||
const bracketHeight = maxMatches * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
mainRounds,
|
||||||
const minWidth = mainRounds.length * COLUMN_WIDTH + Math.max(0, mainRounds.length - 1) * CONNECTOR_WIDTH;
|
matchesByRound,
|
||||||
|
feeders,
|
||||||
|
template?.rounds.map((r) => r.name) ?? mainRounds
|
||||||
|
);
|
||||||
|
const { bracketHeight, minWidth } = geometry;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -419,11 +524,11 @@ export function BracketTreeView({
|
||||||
>
|
>
|
||||||
<div style={{ minWidth }}>
|
<div style={{ minWidth }}>
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
visibleRounds={mainRounds}
|
geometry={geometry}
|
||||||
matchesByRound={matchesByRound}
|
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
bracketHeight={bracketHeight}
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
{thirdPlaceMatch && (
|
{thirdPlaceMatch && (
|
||||||
<div style={{ display: "flex", paddingTop: 20 }}>
|
<div style={{ display: "flex", paddingTop: 20 }}>
|
||||||
|
|
@ -441,6 +546,8 @@ export function BracketTreeView({
|
||||||
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import type { ConferenceGroup } from "~/lib/bracket-templates";
|
import type { BracketTemplate, ConferenceGroup } from "~/lib/bracket-templates";
|
||||||
import { TreeColumns, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
import type { FeederMap } from "~/lib/bracket-layout";
|
||||||
|
import {
|
||||||
|
TreeColumns,
|
||||||
|
bracketGeometry,
|
||||||
|
type BracketMatch,
|
||||||
|
type BracketOwnership,
|
||||||
|
} from "./BracketTreeView";
|
||||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||||||
|
|
||||||
interface NbaBracketLayoutProps {
|
interface NbaBracketLayoutProps {
|
||||||
|
|
@ -10,11 +16,10 @@ interface NbaBracketLayoutProps {
|
||||||
userParticipantIds: Set<string>;
|
userParticipantIds: Set<string>;
|
||||||
conferenceGroups: ConferenceGroup[];
|
conferenceGroups: ConferenceGroup[];
|
||||||
scoringRoundIdx: number;
|
scoringRoundIdx: number;
|
||||||
|
feeders?: FeederMap;
|
||||||
|
template?: BracketTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DESIRED_CARD_HEIGHT = 112;
|
|
||||||
const CARD_GAP = 14;
|
|
||||||
|
|
||||||
function splitMatchesByConference(
|
function splitMatchesByConference(
|
||||||
matchesByRound: Map<string, BracketMatch[]>,
|
matchesByRound: Map<string, BracketMatch[]>,
|
||||||
group: ConferenceGroup
|
group: ConferenceGroup
|
||||||
|
|
@ -28,11 +33,6 @@ function splitMatchesByConference(
|
||||||
return result;
|
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({
|
export function NbaBracketLayout({
|
||||||
rounds,
|
rounds,
|
||||||
matchesByRound,
|
matchesByRound,
|
||||||
|
|
@ -40,7 +40,10 @@ export function NbaBracketLayout({
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
conferenceGroups,
|
conferenceGroups,
|
||||||
scoringRoundIdx,
|
scoringRoundIdx,
|
||||||
|
feeders,
|
||||||
|
template,
|
||||||
}: NbaBracketLayoutProps) {
|
}: NbaBracketLayoutProps) {
|
||||||
|
const roundOrder = template?.rounds.map((r) => r.name) ?? rounds;
|
||||||
// Rounds that belong to any conference group
|
// Rounds that belong to any conference group
|
||||||
const conferenceRoundSet = new Set(
|
const conferenceRoundSet = new Set(
|
||||||
conferenceGroups.flatMap((g) => Object.keys(g.roundMatchNumbers))
|
conferenceGroups.flatMap((g) => Object.keys(g.roundMatchNumbers))
|
||||||
|
|
@ -57,7 +60,7 @@ export function NbaBracketLayout({
|
||||||
const sharedMatches = new Map(
|
const sharedMatches = new Map(
|
||||||
sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []])
|
sharedRounds.map((r) => [r, matchesByRound.get(r) ?? []])
|
||||||
);
|
);
|
||||||
const sharedHeight = bracketHeight(sharedMatches, sharedRounds);
|
const sharedGeometry = bracketGeometry(sharedRounds, sharedMatches, feeders, roundOrder);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -66,7 +69,7 @@ export function NbaBracketLayout({
|
||||||
{conferenceGroups.map((group, gi) => {
|
{conferenceGroups.map((group, gi) => {
|
||||||
const confRounds = conferenceRounds[gi];
|
const confRounds = conferenceRounds[gi];
|
||||||
const confMatches = splitMatchesByConference(matchesByRound, group);
|
const confMatches = splitMatchesByConference(matchesByRound, group);
|
||||||
const height = bracketHeight(confMatches, confRounds);
|
const geometry = bracketGeometry(confRounds, confMatches, feeders, roundOrder);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={group.name}>
|
<div key={group.name}>
|
||||||
|
|
@ -74,11 +77,11 @@ export function NbaBracketLayout({
|
||||||
{group.name}
|
{group.name}
|
||||||
</p>
|
</p>
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
visibleRounds={confRounds}
|
geometry={geometry}
|
||||||
matchesByRound={confMatches}
|
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
bracketHeight={height}
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -87,11 +90,11 @@ export function NbaBracketLayout({
|
||||||
{sharedRounds.length > 0 && (
|
{sharedRounds.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
visibleRounds={sharedRounds}
|
geometry={sharedGeometry}
|
||||||
matchesByRound={sharedMatches}
|
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
bracketHeight={sharedHeight}
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -105,6 +108,8 @@ export function NbaBracketLayout({
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
firstScoringRoundIdx={scoringRoundIdx}
|
firstScoringRoundIdx={scoringRoundIdx}
|
||||||
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@ import { GradientIcon } from "~/components/ui/GradientIcon";
|
||||||
import { RankingsRow } from "./RankingsRow";
|
import { RankingsRow } from "./RankingsRow";
|
||||||
import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
import { BracketTreeView, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
||||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
import { BracketTreePaginated } from "./BracketTreePaginated";
|
||||||
import { getBracketTemplate } from "~/lib/bracket-templates";
|
import { getBracketTemplate, type BracketTemplate } from "~/lib/bracket-templates";
|
||||||
|
import { buildFeederMap } from "~/lib/bracket-layout";
|
||||||
import { NbaBracketLayout } from "./NbaBracketLayout";
|
import { NbaBracketLayout } from "./NbaBracketLayout";
|
||||||
import { TabbedBracketLayout } from "./TabbedBracketLayout";
|
import { TabbedBracketLayout } from "./TabbedBracketLayout";
|
||||||
|
|
||||||
|
|
@ -76,43 +77,6 @@ export function groupMatchesByRound(matches: Match[]): Map<string, Match[]> {
|
||||||
return byRound;
|
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 {
|
interface EliminatedEntry {
|
||||||
participant: Participant;
|
participant: Participant;
|
||||||
score: string | null;
|
score: string | null;
|
||||||
|
|
@ -174,6 +138,182 @@ export function computeEliminatedByRound(
|
||||||
return result;
|
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. */
|
/** Find the index of the first round that has scoring matches. */
|
||||||
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number {
|
function firstScoringRoundIdx(matchesByRound: Map<string, Match[]>, rounds: string[]): number {
|
||||||
for (let i = 0; i < rounds.length; i++) {
|
for (let i = 0; i < rounds.length; i++) {
|
||||||
|
|
@ -215,13 +355,13 @@ export function PlayoffBracket({
|
||||||
const matchesByRound = groupMatchesByRound(matches);
|
const matchesByRound = groupMatchesByRound(matches);
|
||||||
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
|
const scoringRoundIdx = firstScoringRoundIdx(matchesByRound, rounds);
|
||||||
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
|
const template = bracketTemplateId ? getBracketTemplate(bracketTemplateId) : undefined;
|
||||||
|
// What fills each slot, used for both card placement and naming empty slots.
|
||||||
|
const feeders = buildFeederMap(template);
|
||||||
|
|
||||||
const thirdPlaceRound = template?.rounds
|
const consolation = findConsolationRound(template);
|
||||||
.find((r) => template.rounds.some((other) => other.loserFeedsInto === r.name))
|
const thirdPlaceRound = consolation?.round;
|
||||||
?.name;
|
|
||||||
|
|
||||||
// Build elimination rankings
|
// Build elimination rankings
|
||||||
const losersByRound = new Map<string, Array<{ participant: Participant; score: string | null; ownership: TeamOwnership | null }>>();
|
|
||||||
let bracketWinner: Participant | null = null;
|
let bracketWinner: Participant | null = null;
|
||||||
|
|
||||||
const lastRound = rounds[rounds.length - 1];
|
const lastRound = rounds[rounds.length - 1];
|
||||||
|
|
@ -230,43 +370,20 @@ export function PlayoffBracket({
|
||||||
: null;
|
: null;
|
||||||
if (finalMatch?.winner) bracketWinner = finalMatch.winner;
|
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>();
|
const allBracketParticipantIds = new Set<string>();
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id);
|
if (match.participant1Id) allBracketParticipantIds.add(match.participant1Id);
|
||||||
if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id);
|
if (match.participant2Id) allBracketParticipantIds.add(match.participant2Id);
|
||||||
}
|
}
|
||||||
const rankedEntries: EliminatedEntry[] = [];
|
|
||||||
let nextRank = 2;
|
const rankedEntries = computeRankedEntries(
|
||||||
for (let ri = rounds.length - 1; ri >= 0; ri--) {
|
matches,
|
||||||
const roundName = rounds[ri];
|
rounds,
|
||||||
const roundLosers = losersByRound.get(roundName) || [];
|
matchesByRound,
|
||||||
const totalMatchesInRound = matchesByRound.get(roundName)?.length ?? 0;
|
consolation,
|
||||||
if (roundLosers.length > 0) {
|
ownershipMap,
|
||||||
const rankLabel = `T${nextRank}`;
|
roundsWithLosersPlacedLater(template, consolation)
|
||||||
for (const loser of roundLosers) {
|
);
|
||||||
rankedEntries.push({ ...loser, rankLabel });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nextRank += totalMatchesInRound;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
|
const rankedParticipantIds = new Set(rankedEntries.map((e) => e.participant.id));
|
||||||
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);
|
if (bracketWinner) rankedParticipantIds.add(bracketWinner.id);
|
||||||
|
|
@ -284,7 +401,14 @@ export function PlayoffBracket({
|
||||||
const activeParticipants = [...allBracketParticipantIds]
|
const activeParticipants = [...allBracketParticipantIds]
|
||||||
.filter((id) => !rankedParticipantIds.has(id))
|
.filter((id) => !rankedParticipantIds.has(id))
|
||||||
.map((id) => participantMap.get(id))
|
.map((id) => participantMap.get(id))
|
||||||
.filter((p): p is Participant => p !== undefined);
|
.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);
|
||||||
|
});
|
||||||
|
|
||||||
const isDraftedOrScoring = (participantId: string) =>
|
const isDraftedOrScoring = (participantId: string) =>
|
||||||
ownershipMap.has(participantId) || (pointsMap.get(participantId) ?? 0) > 0;
|
ownershipMap.has(participantId) || (pointsMap.get(participantId) ?? 0) > 0;
|
||||||
|
|
@ -320,6 +444,8 @@ export function PlayoffBracket({
|
||||||
userParticipantIds={userParticipantSet}
|
userParticipantIds={userParticipantSet}
|
||||||
phases={template.phases}
|
phases={template.phases}
|
||||||
scoringRoundIdx={scoringRoundIdx}
|
scoringRoundIdx={scoringRoundIdx}
|
||||||
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
) : template?.conferenceGroups ? (
|
) : template?.conferenceGroups ? (
|
||||||
<NbaBracketLayout
|
<NbaBracketLayout
|
||||||
|
|
@ -330,6 +456,8 @@ export function PlayoffBracket({
|
||||||
userParticipantIds={userParticipantSet}
|
userParticipantIds={userParticipantSet}
|
||||||
conferenceGroups={template.conferenceGroups}
|
conferenceGroups={template.conferenceGroups}
|
||||||
scoringRoundIdx={scoringRoundIdx}
|
scoringRoundIdx={scoringRoundIdx}
|
||||||
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
@ -341,6 +469,8 @@ export function PlayoffBracket({
|
||||||
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
ownershipMap={ownershipMap as Map<string, BracketOwnership>}
|
||||||
userParticipantIds={userParticipantSet}
|
userParticipantIds={userParticipantSet}
|
||||||
thirdPlaceRound={thirdPlaceRound}
|
thirdPlaceRound={thirdPlaceRound}
|
||||||
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -353,6 +483,8 @@ export function PlayoffBracket({
|
||||||
userParticipantIds={userParticipantSet}
|
userParticipantIds={userParticipantSet}
|
||||||
firstScoringRoundIdx={scoringRoundIdx}
|
firstScoringRoundIdx={scoringRoundIdx}
|
||||||
thirdPlaceRound={thirdPlaceRound}
|
thirdPlaceRound={thirdPlaceRound}
|
||||||
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,9 @@ interface QualifyingPointsStandingsProps {
|
||||||
function formatQP(raw: string): string {
|
function formatQP(raw: string): string {
|
||||||
const n = parseFloat(raw);
|
const n = parseFloat(raw);
|
||||||
if (isNaN(n)) return "—";
|
if (isNaN(n)) return "—";
|
||||||
return n % 1 === 0 ? n.toString() : n.toFixed(2);
|
// 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QualifyingPointsStandings({
|
export function QualifyingPointsStandings({
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,18 @@
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
import type { BracketPhase, ConferenceGroup } from "~/lib/bracket-templates";
|
import type { BracketPhase, BracketTemplate, ConferenceGroup } from "~/lib/bracket-templates";
|
||||||
import { TreeColumns, BracketMatchSlot, type BracketMatch, type BracketOwnership } from "./BracketTreeView";
|
import type { FeederMap } from "~/lib/bracket-layout";
|
||||||
|
import {
|
||||||
|
TreeColumns,
|
||||||
|
BracketMatchSlot,
|
||||||
|
bracketGeometry,
|
||||||
|
type BracketMatch,
|
||||||
|
type BracketOwnership,
|
||||||
|
} from "./BracketTreeView";
|
||||||
import { BracketTreePaginated } from "./BracketTreePaginated";
|
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 {
|
interface TabbedBracketLayoutProps {
|
||||||
rounds: string[];
|
rounds: string[];
|
||||||
matchesByRound: Map<string, BracketMatch[]>;
|
matchesByRound: Map<string, BracketMatch[]>;
|
||||||
|
|
@ -10,11 +20,10 @@ interface TabbedBracketLayoutProps {
|
||||||
userParticipantIds: Set<string>;
|
userParticipantIds: Set<string>;
|
||||||
phases: BracketPhase[];
|
phases: BracketPhase[];
|
||||||
scoringRoundIdx: number;
|
scoringRoundIdx: number;
|
||||||
|
feeders?: FeederMap;
|
||||||
|
template?: BracketTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CARD_H = 112;
|
|
||||||
const CARD_GAP = 14;
|
|
||||||
|
|
||||||
function groupMatches(
|
function groupMatches(
|
||||||
matchesByRound: Map<string, BracketMatch[]>,
|
matchesByRound: Map<string, BracketMatch[]>,
|
||||||
group: ConferenceGroup
|
group: ConferenceGroup
|
||||||
|
|
@ -29,11 +38,6 @@ function groupMatches(
|
||||||
return out;
|
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 ───────────────────────────────────────────────────────────
|
// ─── Play-In Layout ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface PlayInColumnProps {
|
interface PlayInColumnProps {
|
||||||
|
|
@ -141,7 +145,10 @@ export function TabbedBracketLayout({
|
||||||
userParticipantIds,
|
userParticipantIds,
|
||||||
phases,
|
phases,
|
||||||
scoringRoundIdx,
|
scoringRoundIdx,
|
||||||
|
feeders,
|
||||||
|
template,
|
||||||
}: TabbedBracketLayoutProps) {
|
}: TabbedBracketLayoutProps) {
|
||||||
|
const roundOrder = template?.rounds.map((r) => r.name) ?? rounds;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
{phases.map((phase) => {
|
{phases.map((phase) => {
|
||||||
|
|
@ -152,7 +159,22 @@ export function TabbedBracketLayout({
|
||||||
const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r));
|
const simpleRounds = phase.groups ? [] : (phase.rounds ?? []).filter((r) => rounds.includes(r));
|
||||||
|
|
||||||
const phaseRounds = phase.groups ? [...groupRounds, ...sharedRounds] : simpleRounds;
|
const phaseRounds = phase.groups ? [...groupRounds, ...sharedRounds] : simpleRounds;
|
||||||
const phaseMatchesByRound = new Map(phaseRounds.map((r) => [r, matchesByRound.get(r) ?? []]));
|
// 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 sharedMatchesByRound = new Map(sharedRounds.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);
|
const phaseFirstScoringIdx = phaseRounds.findIndex((r) => rounds.indexOf(r) >= scoringRoundIdx);
|
||||||
|
|
@ -179,50 +201,87 @@ export function TabbedBracketLayout({
|
||||||
{phase.groups.map((group) => {
|
{phase.groups.map((group) => {
|
||||||
const gMatches = groupMatches(matchesByRound, group);
|
const gMatches = groupMatches(matchesByRound, group);
|
||||||
const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined);
|
const gRounds = groupRounds.filter((r) => group.roundMatchNumbers[r] !== undefined);
|
||||||
|
const geometry = bracketGeometry(gRounds, gMatches, feeders, roundOrder);
|
||||||
return (
|
return (
|
||||||
<div key={group.name}>
|
<div key={group.name}>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
|
||||||
{group.name}
|
{group.name}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="w-full overflow-x-auto">
|
||||||
|
<div style={{ minWidth: geometry.minWidth }}>
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
visibleRounds={gRounds}
|
geometry={geometry}
|
||||||
matchesByRound={gMatches}
|
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
bracketHeight={phaseHeight(gMatches, gRounds)}
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{sharedRounds.length > 0 && (
|
{sharedRounds.length > 0 && (
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
visibleRounds={sharedRounds}
|
geometry={bracketGeometry(sharedRounds, sharedMatchesByRound, feeders, roundOrder)}
|
||||||
matchesByRound={sharedMatchesByRound}
|
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
bracketHeight={phaseHeight(sharedMatchesByRound, sharedRounds)}
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<TreeColumns
|
<TreeColumns
|
||||||
visibleRounds={phaseRounds}
|
geometry={bracketGeometry(phaseRounds, phaseMatchesByRound, feeders, roundOrder)}
|
||||||
matchesByRound={phaseMatchesByRound}
|
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
bracketHeight={phaseHeight(phaseMatchesByRound, phaseRounds)}
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile */}
|
{/* Mobile — paged one group at a time, matching the desktop split. Paging a
|
||||||
<div className="md:hidden">
|
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">
|
||||||
{phase.layout === "play-in" ? (
|
{phase.layout === "play-in" ? (
|
||||||
<PlayInLayout
|
<PlayInLayout
|
||||||
matchesByRound={phaseMatchesByRound}
|
matchesByRound={phaseMatchesByRound}
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
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
|
<BracketTreePaginated
|
||||||
rounds={phaseRounds}
|
rounds={phaseRounds}
|
||||||
|
|
@ -230,6 +289,8 @@ export function TabbedBracketLayout({
|
||||||
ownershipMap={ownershipMap}
|
ownershipMap={ownershipMap}
|
||||||
userParticipantIds={userParticipantIds}
|
userParticipantIds={userParticipantIds}
|
||||||
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
|
firstScoringRoundIdx={phaseFirstScoringIdx >= 0 ? phaseFirstScoringIdx : undefined}
|
||||||
|
feeders={feeders}
|
||||||
|
template={template}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
116
app/components/scoring/__tests__/NbaBracketLayout.test.tsx
Normal file
116
app/components/scoring/__tests__/NbaBracketLayout.test.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
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,5 +1,16 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { buildFeederMap, groupMatchesByRound, computeEliminatedByRound } from "../PlayoffBracket";
|
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";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
|
|
@ -52,88 +63,72 @@ describe("groupMatchesByRound", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// buildFeederMap
|
// Rendered LLWS bracket — geometry and empty-slot labels
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("buildFeederMap", () => {
|
describe("PlayoffBracket — rendered LLWS bracket", () => {
|
||||||
it("returns an empty map when there is only one round", () => {
|
const LLWS_ROUNDS = (getBracketTemplate("llws_20")?.rounds ?? []).map((r) => r.name);
|
||||||
const matches = [makeMatch("Finals", 1)];
|
|
||||||
const map = buildFeederMap(groupMatchesByRound(matches), ["Finals"]);
|
/** Every LLWS match, all unplayed, so each slot shows what will fill it. */
|
||||||
expect(map.size).toBe(0);
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps SF slots to the correct QF matches for an 8-team bracket", () => {
|
it("still shows TBD for a directly seeded slot", () => {
|
||||||
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
|
render(
|
||||||
const matches = [
|
<PlayoffBracket
|
||||||
makeMatch("Quarterfinals", 1),
|
matches={emptyLlwsMatches()}
|
||||||
makeMatch("Quarterfinals", 2),
|
rounds={LLWS_ROUNDS}
|
||||||
makeMatch("Quarterfinals", 3),
|
bracketTemplateId="llws_20"
|
||||||
makeMatch("Quarterfinals", 4),
|
/>
|
||||||
makeMatch("Semifinals", 1),
|
);
|
||||||
makeMatch("Semifinals", 2),
|
|
||||||
makeMatch("Finals", 1),
|
|
||||||
];
|
|
||||||
|
|
||||||
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
|
// The opening round is seeded, not fed, so it has nothing to name.
|
||||||
|
expect(screen.getAllByText("TBD").length).toBeGreaterThan(0);
|
||||||
// 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("maps Finals slots to the correct SF matches", () => {
|
it("gives every card the same height, including a lone final", () => {
|
||||||
const rounds = ["Quarterfinals", "Semifinals", "Finals"];
|
const { container } = render(
|
||||||
const matches = [
|
<PlayoffBracket
|
||||||
makeMatch("Quarterfinals", 1),
|
matches={emptyLlwsMatches()}
|
||||||
makeMatch("Quarterfinals", 2),
|
rounds={LLWS_ROUNDS}
|
||||||
makeMatch("Quarterfinals", 3),
|
bracketTemplateId="llws_20"
|
||||||
makeMatch("Quarterfinals", 4),
|
/>
|
||||||
makeMatch("Semifinals", 1),
|
);
|
||||||
makeMatch("Semifinals", 2),
|
|
||||||
makeMatch("Finals", 1),
|
|
||||||
];
|
|
||||||
|
|
||||||
const map = buildFeederMap(groupMatchesByRound(matches), rounds);
|
const heights = new Set(
|
||||||
|
[...container.querySelectorAll<HTMLElement>("[data-match-id]")].map(
|
||||||
expect(map.get("Finals:1:p1")).toEqual({ round: "Semifinals", matchNumber: 1 });
|
(el) => el.style.height
|
||||||
expect(map.get("Finals:1:p2")).toEqual({ round: "Semifinals", matchNumber: 2 });
|
)
|
||||||
});
|
);
|
||||||
|
// Previously a one-match column stretched its card to fill the bracket height.
|
||||||
it("does not add an entry when the source match does not exist in the previous round", () => {
|
expect(heights.size).toBe(1);
|
||||||
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 });
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -291,3 +286,526 @@ 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,4 +1,5 @@
|
||||||
import { format, parseISO } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { useHasMounted } from "~/hooks/useHasMounted";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
|
@ -9,6 +10,7 @@ interface ScheduleEvent {
|
||||||
name: string;
|
name: string;
|
||||||
eventDate: string | null;
|
eventDate: string | null;
|
||||||
eventStartsAt?: Date | string | null;
|
eventStartsAt?: Date | string | null;
|
||||||
|
earliestGameTime?: string | null;
|
||||||
eventType: string;
|
eventType: string;
|
||||||
isComplete: boolean;
|
isComplete: boolean;
|
||||||
completedAt?: Date | string | null;
|
completedAt?: Date | string | null;
|
||||||
|
|
@ -25,28 +27,13 @@ interface EventScheduleProps {
|
||||||
function formatEventDate(dateStr: string | null | undefined): string {
|
function formatEventDate(dateStr: string | null | undefined): string {
|
||||||
if (!dateStr) return "Date TBD";
|
if (!dateStr) return "Date TBD";
|
||||||
try {
|
try {
|
||||||
// eventDate is stored as a date string "YYYY-MM-DD"
|
const [year, month, day] = dateStr.split("-").map(Number);
|
||||||
return format(parseISO(dateStr), "MMM d, yyyy");
|
return format(new Date(year, month - 1, day), "MMM d, yyyy");
|
||||||
} catch {
|
} catch {
|
||||||
return dateStr;
|
return dateStr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the display date for an event.
|
|
||||||
* Prefers eventStartsAt (a full UTC timestamp → correct local date) over
|
|
||||||
* eventDate (the UTC calendar date, which is off by one day for late-night
|
|
||||||
* events in UTC-negative timezones like PDT).
|
|
||||||
*/
|
|
||||||
function getDisplayDate(event: Pick<ScheduleEvent, "eventDate" | "eventStartsAt">): string {
|
|
||||||
if (event.eventStartsAt) {
|
|
||||||
const d = new Date(event.eventStartsAt);
|
|
||||||
if (!isNaN(d.getTime())) {
|
|
||||||
return format(d, "MMM d, yyyy");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return formatEventDate(event.eventDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEventTypeLabel(eventType: string): string {
|
function getEventTypeLabel(eventType: string): string {
|
||||||
switch (eventType) {
|
switch (eventType) {
|
||||||
|
|
@ -64,6 +51,19 @@ function getEventTypeLabel(eventType: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventSchedule({ upcomingEvents, recentEvents, leagueId, sportsSeasonId }: EventScheduleProps) {
|
export function EventSchedule({ upcomingEvents, recentEvents, leagueId, sportsSeasonId }: EventScheduleProps) {
|
||||||
|
const isMounted = useHasMounted();
|
||||||
|
|
||||||
|
function getDisplayDate(event: Pick<ScheduleEvent, "eventDate" | "eventStartsAt" | "earliestGameTime">): string {
|
||||||
|
if (isMounted) {
|
||||||
|
const ts = event.earliestGameTime ?? (event.eventStartsAt as string | null | undefined);
|
||||||
|
if (ts) {
|
||||||
|
const d = new Date(ts);
|
||||||
|
if (!isNaN(d.getTime())) return format(d, "MMM d, yyyy");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return formatEventDate(event.eventDate);
|
||||||
|
}
|
||||||
|
|
||||||
const hasUpcoming = upcomingEvents.length > 0;
|
const hasUpcoming = upcomingEvents.length > 0;
|
||||||
const hasRecent = recentEvents.length > 0;
|
const hasRecent = recentEvents.length > 0;
|
||||||
|
|
||||||
|
|
@ -112,12 +112,14 @@ export function EventSchedule({ upcomingEvents, recentEvents, leagueId, sportsSe
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
|
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
|
||||||
<Calendar className="h-3 w-3" />
|
<Calendar className="h-3 w-3" />
|
||||||
<span suppressHydrationWarning>{getDisplayDate(event)}</span>
|
<span>{getDisplayDate(event)}</span>
|
||||||
{event.eventStartsAt && (
|
{isMounted && (() => {
|
||||||
<span suppressHydrationWarning>
|
const ts = event.earliestGameTime ?? (event.eventStartsAt as string | null | undefined);
|
||||||
· {format(new Date(event.eventStartsAt), "h:mm a")}
|
if (!ts) return null;
|
||||||
</span>
|
const d = new Date(ts);
|
||||||
)}
|
if (isNaN(d.getTime())) return null;
|
||||||
|
return <span>· {d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}</span>;
|
||||||
|
})()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{event.eventType === "schedule_event" ? (
|
{event.eventType === "schedule_event" ? (
|
||||||
|
|
@ -178,7 +180,7 @@ export function EventSchedule({ upcomingEvents, recentEvents, leagueId, sportsSe
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
|
<p className="text-xs text-muted-foreground flex items-center gap-1 mt-0.5">
|
||||||
<Calendar className="h-3 w-3" />
|
<Calendar className="h-3 w-3" />
|
||||||
<span suppressHydrationWarning>{getDisplayDate(event)}</span>
|
<span>{getDisplayDate(event)}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{event.eventType !== "schedule_event" && (
|
{event.eventType !== "schedule_event" && (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import type { GroupStandingsRow } from "~/models/group-stage-match";
|
import type { GroupStandingsRow } from "~/models/group-stage-match";
|
||||||
|
import { utcIsoToLocalDateTime } from "~/lib/date-utils";
|
||||||
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
|
import { TeamOwnerBadge } from "~/components/ui/team-owner-badge";
|
||||||
|
|
||||||
interface GroupMatch {
|
interface GroupMatch {
|
||||||
|
|
@ -38,6 +40,23 @@ function MatchResult({ match }: { match: GroupMatch }) {
|
||||||
const p1 = match.participant1?.name ?? "?";
|
const p1 = match.participant1?.name ?? "?";
|
||||||
const p2 = match.participant2?.name ?? "?";
|
const p2 = match.participant2?.name ?? "?";
|
||||||
|
|
||||||
|
// SSR renders UTC time (stable); useEffect replaces with the user's local time after hydration.
|
||||||
|
const [kickoffDisplay, setKickoffDisplay] = useState(() =>
|
||||||
|
match.scheduledAt
|
||||||
|
? new Date(match.scheduledAt).toLocaleTimeString("en-US", {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
timeZone: "UTC",
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!match.scheduledAt) return;
|
||||||
|
setKickoffDisplay(
|
||||||
|
new Date(match.scheduledAt).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })
|
||||||
|
);
|
||||||
|
}, [match.scheduledAt]);
|
||||||
|
|
||||||
if (match.isComplete && match.participant1Score !== null && match.participant2Score !== null) {
|
if (match.isComplete && match.participant1Score !== null && match.participant2Score !== null) {
|
||||||
const s1 = match.participant1Score;
|
const s1 = match.participant1Score;
|
||||||
const s2 = match.participant2Score;
|
const s2 = match.participant2Score;
|
||||||
|
|
@ -61,7 +80,6 @@ function MatchResult({ match }: { match: GroupMatch }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const kickoff = match.scheduledAt ? new Date(match.scheduledAt) : null;
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0">
|
<div className="flex flex-col gap-0">
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
|
@ -69,9 +87,9 @@ function MatchResult({ match }: { match: GroupMatch }) {
|
||||||
<span className="shrink-0 text-muted-foreground/60">vs</span>
|
<span className="shrink-0 text-muted-foreground/60">vs</span>
|
||||||
<span className="truncate max-w-[80px]">{p2}</span>
|
<span className="truncate max-w-[80px]">{p2}</span>
|
||||||
</div>
|
</div>
|
||||||
{kickoff && (
|
{kickoffDisplay && (
|
||||||
<span className="text-[10px] text-muted-foreground/50">
|
<span className="text-[10px] text-muted-foreground/50">
|
||||||
{kickoff.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}
|
{kickoffDisplay}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -83,6 +101,12 @@ export function GroupStageStandings({
|
||||||
ownershipMap,
|
ownershipMap,
|
||||||
showEmpty = false,
|
showEmpty = false,
|
||||||
}: GroupStageStandingsProps) {
|
}: 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
|
const visibleGroups = showEmpty
|
||||||
? groups
|
? groups
|
||||||
: groups.filter((g) => g.matches.some((m) => m.isComplete) || g.standings.length > 0);
|
: groups.filter((g) => g.matches.some((m) => m.isComplete) || g.standings.length > 0);
|
||||||
|
|
@ -93,7 +117,7 @@ export function GroupStageStandings({
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-semibold">Group Stage</h3>
|
<h3 className="text-lg font-semibold">Group Stage</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{groups.map((group) => (
|
{visibleGroups.map((group) => (
|
||||||
<div
|
<div
|
||||||
key={group.groupName}
|
key={group.groupName}
|
||||||
className="rounded-lg border bg-card text-card-foreground shadow-sm overflow-hidden"
|
className="rounded-lg border bg-card text-card-foreground shadow-sm overflow-hidden"
|
||||||
|
|
@ -169,26 +193,46 @@ export function GroupStageStandings({
|
||||||
if (!b.scheduledAt) return -1;
|
if (!b.scheduledAt) return -1;
|
||||||
return new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime();
|
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.
|
||||||
const byDay = new Map<string, GroupMatch[]>();
|
const byDay = new Map<string, GroupMatch[]>();
|
||||||
for (const m of sorted) {
|
for (const m of sorted) {
|
||||||
const key = m.scheduledAt
|
const key = m.scheduledAt
|
||||||
? new Date(m.scheduledAt).toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
? (mounted
|
||||||
|
? utcIsoToLocalDateTime(m.scheduledAt).slice(0, 10)
|
||||||
|
: m.scheduledAt.slice(0, 10))
|
||||||
: "TBD";
|
: "TBD";
|
||||||
if (!byDay.has(key)) byDay.set(key, []);
|
if (!byDay.has(key)) byDay.set(key, []);
|
||||||
byDay.get(key)?.push(m);
|
byDay.get(key)?.push(m);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="px-4 pb-3 space-y-0.5 border-t pt-2">
|
<div className="px-4 pb-3 space-y-0.5 border-t pt-2">
|
||||||
{[...byDay.entries()].map(([day, matches]) => (
|
{[...byDay.entries()].map(([key, matches]) => {
|
||||||
<div key={day} className="space-y-0.5">
|
// 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.
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div key={key} className="space-y-0.5">
|
||||||
<p className="text-[10px] text-muted-foreground/70 uppercase tracking-wide pt-1">
|
<p className="text-[10px] text-muted-foreground/70 uppercase tracking-wide pt-1">
|
||||||
{day}
|
{dayLabel}
|
||||||
</p>
|
</p>
|
||||||
{matches.map((m) => (
|
{matches.map((m) => (
|
||||||
<MatchResult key={m.id} match={m} />
|
<MatchResult key={m.id} match={m} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { Calendar, ChevronRight, Users } from "lucide-react";
|
import { Calendar, ChevronRight, Users } from "lucide-react";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useHasMounted } from "~/hooks/useHasMounted";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
|
|
@ -58,9 +59,10 @@ function ParticipantList({ participants }: { participants: Array<{ id: string; n
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventRow({ event, showLeague }: { event: CalendarPanelEvent; showLeague: boolean }) {
|
function EventRow({ event, showLeague }: { event: CalendarPanelEvent; showLeague: boolean }) {
|
||||||
|
const isMounted = useHasMounted();
|
||||||
// Prefer game-level scheduledAt over event-level eventDate for display
|
// Prefer game-level scheduledAt over event-level eventDate for display
|
||||||
const gameDate = event.earliestGameTime ? new Date(event.earliestGameTime) : null;
|
const gameDate = event.earliestGameTime ? new Date(event.earliestGameTime) : null;
|
||||||
const dateStr = gameDate
|
const dateStr = isMounted && gameDate
|
||||||
? format(gameDate, "MMM d")
|
? format(gameDate, "MMM d")
|
||||||
: formatEventDate(event.eventDate);
|
: formatEventDate(event.eventDate);
|
||||||
const participantCount = event.relevantParticipants.length;
|
const participantCount = event.relevantParticipants.length;
|
||||||
|
|
@ -73,14 +75,11 @@ function EventRow({ event, showLeague }: { event: CalendarPanelEvent; showLeague
|
||||||
<div className="flex items-start gap-3 py-2.5 border-b last:border-0 group">
|
<div className="flex items-start gap-3 py-2.5 border-b last:border-0 group">
|
||||||
{/* Date pill */}
|
{/* Date pill */}
|
||||||
<div className="w-14 shrink-0 text-center">
|
<div className="w-14 shrink-0 text-center">
|
||||||
<span className="text-xs font-semibold text-electric" suppressHydrationWarning>
|
<span className="text-xs font-semibold text-electric">
|
||||||
{dateStr ?? "TBD"}
|
{dateStr ?? "TBD"}
|
||||||
</span>
|
</span>
|
||||||
{gameDate && (
|
{isMounted && gameDate && (
|
||||||
<p
|
<p className="text-xs text-muted-foreground leading-tight">
|
||||||
className="text-xs text-muted-foreground leading-tight"
|
|
||||||
suppressHydrationWarning
|
|
||||||
>
|
|
||||||
{gameDate.toLocaleTimeString(undefined, {
|
{gameDate.toLocaleTimeString(undefined, {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
|
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent } from "~/components/ui/card";
|
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 { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "~/components/ui/table";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
|
||||||
|
|
@ -97,6 +105,54 @@ 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
|
* Display detailed team score breakdown with all drafted participants
|
||||||
* Phase 4.3: Team breakdown pages
|
* Phase 4.3: Team breakdown pages
|
||||||
|
|
@ -130,17 +186,23 @@ 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);
|
const allPicks = sortPicks(breakdown.picks, sortColumn, sortDirection);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold">{breakdown.team.name}</h1>
|
<h1 className="text-2xl sm:text-3xl font-bold">{breakdown.team.name}</h1>
|
||||||
<p className="text-muted-foreground mt-1">Team Score Breakdown</p>
|
<p className="text-muted-foreground mt-1">Team Score Breakdown</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-left sm:text-right">
|
||||||
<div className="text-4xl font-bold text-primary">
|
<div className="text-4xl font-bold text-primary">
|
||||||
{breakdown.actualPoints.toFixed(2)}
|
{breakdown.actualPoints.toFixed(2)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -164,8 +226,8 @@ export function TeamScoreBreakdown({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* All picks — single flat table */}
|
{/* All picks — desktop table */}
|
||||||
<Card>
|
<Card className="hidden md:block">
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
|
|
@ -198,9 +260,7 @@ export function TeamScoreBreakdown({
|
||||||
{allPicks.map((pick) => (
|
{allPicks.map((pick) => (
|
||||||
<TableRow key={pick.pickNumber}>
|
<TableRow key={pick.pickNumber}>
|
||||||
<TableCell className="text-muted-foreground">
|
<TableCell className="text-muted-foreground">
|
||||||
{numTeams > 0
|
{pickLabel(pick, numTeams)}
|
||||||
? `${pick.round}.${String(pick.pickNumber - (pick.round - 1) * numTeams).padStart(2, "0")}`
|
|
||||||
: `#${pick.pickNumber}`}
|
|
||||||
<span className="text-xs ml-1">(#{pick.pickNumber})</span>
|
<span className="text-xs ml-1">(#{pick.pickNumber})</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|
@ -215,31 +275,10 @@ export function TeamScoreBreakdown({
|
||||||
{pick.participant.name}
|
{pick.participant.name}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
{pick.isComplete && !pick.isPartialScore ? (
|
<PositionCell pick={pick} />
|
||||||
(pick.finalPosition ?? 0) === 0 ? (
|
|
||||||
<Badge variant="secondary">Did Not Score</Badge>
|
|
||||||
) : (
|
|
||||||
<PlacementBadge position={pick.finalPosition ?? 0} />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<Badge variant="outline">Pending</Badge>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
{pick.isComplete && !pick.isPartialScore ? (
|
<PointsValue pick={pick} />
|
||||||
<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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|
@ -248,6 +287,70 @@ export function TeamScoreBreakdown({
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Navigation */}
|
||||||
<div className="flex justify-between pt-4">
|
<div className="flex justify-between pt-4">
|
||||||
<Link
|
<Link
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { BrowserRouter } from "react-router";
|
import { BrowserRouter } from "react-router";
|
||||||
import { TeamScoreBreakdown } from "../TeamScoreBreakdown";
|
import { TeamScoreBreakdown } from "../TeamScoreBreakdown";
|
||||||
|
|
@ -160,9 +160,10 @@ describe("TeamScoreBreakdown", () => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("1.01")).toBeInTheDocument();
|
// Pick labels render in both the desktop table and mobile cards.
|
||||||
expect(screen.getByText("1.02")).toBeInTheDocument();
|
expect(screen.getAllByText("1.01").length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getByText("2.01")).toBeInTheDocument();
|
expect(screen.getAllByText("1.02").length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getAllByText("2.01").length).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show overall pick number in parentheses", () => {
|
it("should show overall pick number in parentheses", () => {
|
||||||
|
|
@ -176,9 +177,9 @@ describe("TeamScoreBreakdown", () => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("(#1)")).toBeInTheDocument();
|
expect(screen.getAllByText("(#1)").length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getByText("(#2)")).toBeInTheDocument();
|
expect(screen.getAllByText("(#2)").length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getByText("(#3)")).toBeInTheDocument();
|
expect(screen.getAllByText("(#3)").length).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should fall back to #pickNumber when numTeams is 0", () => {
|
it("should fall back to #pickNumber when numTeams is 0", () => {
|
||||||
|
|
@ -192,9 +193,9 @@ describe("TeamScoreBreakdown", () => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("#1")).toBeInTheDocument();
|
expect(screen.getAllByText("#1").length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getByText("#2")).toBeInTheDocument();
|
expect(screen.getAllByText("#2").length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getByText("#3")).toBeInTheDocument();
|
expect(screen.getAllByText("#3").length).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -210,9 +211,10 @@ describe("TeamScoreBreakdown", () => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("Team A")).toBeInTheDocument();
|
const table = within(screen.getByRole("table"));
|
||||||
expect(screen.getByText("Driver B")).toBeInTheDocument();
|
expect(table.getByText("Team A")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Team C")).toBeInTheDocument();
|
expect(table.getByText("Driver B")).toBeInTheDocument();
|
||||||
|
expect(table.getByText("Team C")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show sport names as links to sport season pages", () => {
|
it("should show sport names as links to sport season pages", () => {
|
||||||
|
|
@ -226,14 +228,15 @@ describe("TeamScoreBreakdown", () => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const nflLinks = screen.getAllByRole("link", { name: "NFL" });
|
const table = within(screen.getByRole("table"));
|
||||||
|
const nflLinks = table.getAllByRole("link", { name: "NFL" });
|
||||||
expect(nflLinks.length).toBeGreaterThanOrEqual(1);
|
expect(nflLinks.length).toBeGreaterThanOrEqual(1);
|
||||||
expect(nflLinks[0]).toHaveAttribute(
|
expect(nflLinks[0]).toHaveAttribute(
|
||||||
"href",
|
"href",
|
||||||
`/leagues/${mockLeagueId}/sports-seasons/ss-nfl`
|
`/leagues/${mockLeagueId}/sports-seasons/ss-nfl`
|
||||||
);
|
);
|
||||||
|
|
||||||
const f1Link = screen.getByRole("link", { name: "F1" });
|
const f1Link = table.getByRole("link", { name: "F1" });
|
||||||
expect(f1Link).toHaveAttribute(
|
expect(f1Link).toHaveAttribute(
|
||||||
"href",
|
"href",
|
||||||
`/leagues/${mockLeagueId}/sports-seasons/ss-f1`
|
`/leagues/${mockLeagueId}/sports-seasons/ss-f1`
|
||||||
|
|
@ -251,8 +254,9 @@ describe("TeamScoreBreakdown", () => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("1st")).toBeInTheDocument();
|
const table = within(screen.getByRole("table"));
|
||||||
expect(screen.getByText("3rd")).toBeInTheDocument();
|
expect(table.getByText("1st")).toBeInTheDocument();
|
||||||
|
expect(table.getByText("3rd")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show Pending badge for incomplete participants", () => {
|
it("should show Pending badge for incomplete participants", () => {
|
||||||
|
|
@ -266,7 +270,8 @@ describe("TeamScoreBreakdown", () => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("Pending")).toBeInTheDocument();
|
const table = within(screen.getByRole("table"));
|
||||||
|
expect(table.getByText("Pending")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show Did Not Score badge when finalPosition is 0", () => {
|
it("should show Did Not Score badge when finalPosition is 0", () => {
|
||||||
|
|
@ -291,7 +296,8 @@ describe("TeamScoreBreakdown", () => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("Did Not Score")).toBeInTheDocument();
|
const table = within(screen.getByRole("table"));
|
||||||
|
expect(table.getByText("Did Not Score")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show Did Not Score badge when isComplete but finalPosition is null", () => {
|
it("should show Did Not Score badge when isComplete but finalPosition is null", () => {
|
||||||
|
|
@ -318,7 +324,8 @@ describe("TeamScoreBreakdown", () => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("Did Not Score")).toBeInTheDocument();
|
const table = within(screen.getByRole("table"));
|
||||||
|
expect(table.getByText("Did Not Score")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should show projected EV for incomplete participants", () => {
|
it("should show projected EV for incomplete participants", () => {
|
||||||
|
|
@ -333,8 +340,9 @@ describe("TeamScoreBreakdown", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Incomplete participant shows 0.00 (actual) and 25.00 (EV) in the row
|
// Incomplete participant shows 0.00 (actual) and 25.00 (EV) in the row
|
||||||
expect(screen.getByText("0.00")).toBeInTheDocument();
|
const table = within(screen.getByRole("table"));
|
||||||
expect(screen.getByText("25.00")).toBeInTheDocument();
|
expect(table.getByText("0.00")).toBeInTheDocument();
|
||||||
|
expect(table.getByText("25.00")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should display 0.00 for completed picks with zero points", () => {
|
it("should display 0.00 for completed picks with zero points", () => {
|
||||||
|
|
@ -353,7 +361,8 @@ describe("TeamScoreBreakdown", () => {
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("0.00")).toBeInTheDocument();
|
const table = within(screen.getByRole("table"));
|
||||||
|
expect(table.getByText("0.00")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -501,6 +510,96 @@ 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", () => {
|
describe("Without Standing Data", () => {
|
||||||
it("should render without standing prop", () => {
|
it("should render without standing prop", () => {
|
||||||
renderWithRouter(
|
renderWithRouter(
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export function AccountSection({ email, linkedAccounts }: Props) {
|
||||||
setLinkingDiscord(true);
|
setLinkingDiscord(true);
|
||||||
setLinkError(null);
|
setLinkError(null);
|
||||||
try {
|
try {
|
||||||
await authClient.linkSocial({ provider: "discord", callbackURL: "/settings?section=account" });
|
await authClient.linkSocial({ provider: "discord", callbackURL: "/settings/account" });
|
||||||
} catch {
|
} catch {
|
||||||
setLinkError("Failed to connect Discord. Please try again.");
|
setLinkError("Failed to connect Discord. Please try again.");
|
||||||
setLinkingDiscord(false);
|
setLinkingDiscord(false);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useFetcher } from "react-router";
|
import { Link, useFetcher } from "react-router";
|
||||||
import { Switch } from "~/components/ui/switch";
|
import { Switch } from "~/components/ui/switch";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
|
|
||||||
|
|
@ -14,10 +14,9 @@ type Props = {
|
||||||
discordPingEnabled: boolean;
|
discordPingEnabled: boolean;
|
||||||
hasDiscordLinked: boolean;
|
hasDiscordLinked: boolean;
|
||||||
draftEmailNotificationsEnabled: boolean;
|
draftEmailNotificationsEnabled: boolean;
|
||||||
onNavigateToAccount: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, draftEmailNotificationsEnabled, onNavigateToAccount }: Props) {
|
export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, draftEmailNotificationsEnabled }: Props) {
|
||||||
const discordFetcher = useFetcher();
|
const discordFetcher = useFetcher();
|
||||||
const emailFetcher = useFetcher();
|
const emailFetcher = useFetcher();
|
||||||
|
|
||||||
|
|
@ -87,13 +86,12 @@ export function NotificationsSection({ discordPingEnabled, hasDiscordLinked, dra
|
||||||
{!hasDiscordLinked ? (
|
{!hasDiscordLinked ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Link your Discord account in{" "}
|
Link your Discord account in{" "}
|
||||||
<button
|
<Link
|
||||||
type="button"
|
to="/settings/account"
|
||||||
onClick={onNavigateToAccount}
|
|
||||||
className="text-primary underline-offset-4 hover:underline"
|
className="text-primary underline-offset-4 hover:underline"
|
||||||
>
|
>
|
||||||
Account settings
|
Account settings
|
||||||
</button>{" "}
|
</Link>{" "}
|
||||||
to enable Discord pings for league notifications.
|
to enable Discord pings for league notifications.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ describe("AccountSection", () => {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockLinkSocial).toHaveBeenCalledWith({
|
expect(mockLinkSocial).toHaveBeenCalledWith({
|
||||||
provider: "discord",
|
provider: "discord",
|
||||||
callbackURL: "/settings?section=account",
|
callbackURL: "/settings/account",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,26 @@
|
||||||
import * as Sentry from "@sentry/react-router";
|
import * as Sentry from "@sentry/react-router";
|
||||||
import { PassThrough } from "node:stream";
|
import { PassThrough } from "node:stream";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
|
import { shouldReportServerError } from "~/lib/error-reporting";
|
||||||
|
|
||||||
import type { AppLoadContext, EntryContext } from "react-router";
|
import type { AppLoadContext, EntryContext, HandleErrorFunction } from "react-router";
|
||||||
import { createReadableStreamFromReadable } from "@react-router/node";
|
import { createReadableStreamFromReadable } from "@react-router/node";
|
||||||
import { ServerRouter } from "react-router";
|
import { ServerRouter } from "react-router";
|
||||||
import { isbot } from "isbot";
|
import { isbot } from "isbot";
|
||||||
import type { RenderToPipeableStreamOptions } from "react-dom/server";
|
import type { RenderToPipeableStreamOptions } from "react-dom/server";
|
||||||
import { renderToPipeableStream } from "react-dom/server";
|
import { renderToPipeableStream } from "react-dom/server";
|
||||||
|
|
||||||
export const handleError = Sentry.createSentryHandleError({
|
const sentryHandleError = Sentry.createSentryHandleError({
|
||||||
logErrors: true,
|
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;
|
export const streamTimeout = 5_000;
|
||||||
|
|
||||||
async function handleRequest(
|
async function handleRequest(
|
||||||
|
|
|
||||||
7
app/hooks/useHasMounted.ts
Normal file
7
app/hooks/useHasMounted.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
const subscribe = () => () => {};
|
||||||
|
|
||||||
|
export function useHasMounted(): boolean {
|
||||||
|
return useSyncExternalStore(subscribe, () => true, () => false);
|
||||||
|
}
|
||||||
117
app/lib/__tests__/afl-wildcard-reseed.test.ts
Normal file
117
app/lib/__tests__/afl-wildcard-reseed.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
/**
|
||||||
|
* 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/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
533
app/lib/__tests__/bracket-layout.test.ts
Normal file
533
app/lib/__tests__/bracket-layout.test.ts
Normal file
|
|
@ -0,0 +1,533 @@
|
||||||
|
/**
|
||||||
|
* 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 { describe, it, expect } from "vitest";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
import { localDateTimeToUtcIso, toEventSortKey } from "../date-utils";
|
import { localDateTimeToUtcIso, toEventSortKey, utcIsoToLocalDateTime } from "../date-utils";
|
||||||
|
|
||||||
describe("localDateTimeToUtcIso", () => {
|
describe("localDateTimeToUtcIso", () => {
|
||||||
it("returns null for empty string", () => {
|
it("returns null for empty string", () => {
|
||||||
|
|
@ -118,6 +118,46 @@ 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", () => {
|
describe("toEventSortKey", () => {
|
||||||
it("prefers earliestGameTime over eventDate", () => {
|
it("prefers earliestGameTime over eventDate", () => {
|
||||||
const key = toEventSortKey({ eventDate: "2026-03-01", earliestGameTime: "2026-03-17T10:45:00.000Z" });
|
const key = toEventSortKey({ eventDate: "2026-03-01", earliestGameTime: "2026-03-17T10:45:00.000Z" });
|
||||||
|
|
|
||||||
230
app/lib/__tests__/error-reporting.test.ts
Normal file
230
app/lib/__tests__/error-reporting.test.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
133
app/lib/__tests__/fifa-2026-bracket.test.ts
Normal file
133
app/lib/__tests__/fifa-2026-bracket.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
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,6 +14,11 @@ describe("normalizeTeamName", () => {
|
||||||
const name = "oklahoma city thunder";
|
const name = "oklahoma city thunder";
|
||||||
expect(normalizeTeamName(name)).toBe(name);
|
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", () => {
|
describe("findMatchingTeamName", () => {
|
||||||
|
|
@ -38,6 +43,12 @@ describe("findMatchingTeamName", () => {
|
||||||
expect(findMatchingTeamName("Oklahoma City", participants)).toBe("Oklahoma City Thunder");
|
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", () => {
|
it("returns null for unmatched name", () => {
|
||||||
expect(findMatchingTeamName("Phoenix Suns", participants)).toBeNull();
|
expect(findMatchingTeamName("Phoenix Suns", participants)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
99
app/lib/afl-wildcard-reseed.ts
Normal file
99
app/lib/afl-wildcard-reseed.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
420
app/lib/bracket-layout.ts
Normal file
420
app/lib/bracket-layout.ts
Normal file
|
|
@ -0,0 +1,420 @@
|
||||||
|
/**
|
||||||
|
* 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,6 +19,32 @@ export interface BracketRound {
|
||||||
* When set, the loser of each match in this round is placed into the target round.
|
* When set, the loser of each match in this round is placed into the target round.
|
||||||
*/
|
*/
|
||||||
loserFeedsInto?: string | null;
|
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 {
|
export interface GroupStageConfig {
|
||||||
|
|
@ -455,6 +481,31 @@ 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)
|
* NCAA March Madness (68 teams)
|
||||||
* First Four (play-in) → Round of 64 → Round of 32 → Sweet Sixteen → Elite Eight → Final Four → Championship
|
* First Four (play-in) → Round of 64 → Round of 32 → Sweet Sixteen → Elite Eight → Final Four → Championship
|
||||||
|
|
@ -652,7 +703,8 @@ export const NFL_14: BracketTemplate = {
|
||||||
* - Wildcard Round: 7v10, 8v9 (losers eliminated with 0 points)
|
* - Wildcard Round: 7v10, 8v9 (losers eliminated with 0 points)
|
||||||
* - Week 1 Finals:
|
* - Week 1 Finals:
|
||||||
* - Qualifying Finals: 1v4, 2v3 (losers get second chance)
|
* - Qualifying Finals: 1v4, 2v3 (losers get second chance)
|
||||||
* - Elimination Finals: 5v8(wildcard winner), 6v7(wildcard winner) (losers share 7th-8th)
|
* - 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)
|
||||||
* - Week 2: Semi-Finals (QF losers vs EF winners, losers share 5th-6th)
|
* - 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 3: Preliminary Finals (QF winners vs SF winners, losers share 3rd-4th)
|
||||||
* - Week 4: Grand Final (1st vs 2nd)
|
* - Week 4: Grand Final (1st vs 2nd)
|
||||||
|
|
@ -670,18 +722,32 @@ export const AFL_10: BracketTemplate = {
|
||||||
matchCount: 2,
|
matchCount: 2,
|
||||||
feedsInto: "Elimination Finals",
|
feedsInto: "Elimination Finals",
|
||||||
isScoring: false, // Losers get 0 points (9th-10th)
|
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",
|
name: "Qualifying Finals",
|
||||||
matchCount: 2,
|
matchCount: 2,
|
||||||
feedsInto: "Preliminary Finals", // Winners get bye
|
feedsInto: "Preliminary Finals", // Winners get bye
|
||||||
isScoring: false, // Losers get second chance (go to Semi-Finals)
|
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",
|
name: "Elimination Finals",
|
||||||
matchCount: 2,
|
matchCount: 2,
|
||||||
feedsInto: "Semi-Finals",
|
feedsInto: "Semi-Finals",
|
||||||
isScoring: true, // Losers share 7th-8th
|
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",
|
name: "Semi-Finals",
|
||||||
|
|
@ -909,6 +975,258 @@ 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
|
* All available bracket templates
|
||||||
*/
|
*/
|
||||||
|
|
@ -923,8 +1241,10 @@ export const BRACKET_TEMPLATES: Record<string, BracketTemplate> = {
|
||||||
afl_10: AFL_10,
|
afl_10: AFL_10,
|
||||||
fifa_48: FIFA_48,
|
fifa_48: FIFA_48,
|
||||||
darts_128: DARTS_128,
|
darts_128: DARTS_128,
|
||||||
|
tennis_128: TENNIS_128,
|
||||||
cfp_12: CFP_12,
|
cfp_12: CFP_12,
|
||||||
nba_20: NBA_20,
|
nba_20: NBA_20,
|
||||||
|
llws_20: LLWS_20,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -980,6 +1300,18 @@ export function getScoringRoundType(
|
||||||
const round = template.rounds.find((r) => r.name === roundName);
|
const round = template.rounds.find((r) => r.name === roundName);
|
||||||
if (!round || !round.isScoring) return null;
|
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
|
// Special handling for AFL finals
|
||||||
if (template.id === "afl_10") {
|
if (template.id === "afl_10") {
|
||||||
if (roundName === "Elimination Finals") return "quarterfinals"; // Losers share 7-8th
|
if (roundName === "Elimination Finals") return "quarterfinals"; // Losers share 7-8th
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,9 @@
|
||||||
* clear the field. Always write the result to a separate hidden input, not back to
|
* clear the field. Always write the result to a separate hidden input, not back to
|
||||||
* the datetime-local input itself.
|
* the datetime-local input itself.
|
||||||
*/
|
*/
|
||||||
import { format, parseISO } from "date-fns";
|
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").
|
* Format a YYYY-MM-DD date string for display (e.g. "Apr 9").
|
||||||
|
|
@ -25,7 +27,8 @@ import { format, parseISO } from "date-fns";
|
||||||
export function formatEventDate(dateStr: string | null | undefined): string | null {
|
export function formatEventDate(dateStr: string | null | undefined): string | null {
|
||||||
if (!dateStr) return null;
|
if (!dateStr) return null;
|
||||||
try {
|
try {
|
||||||
return format(parseISO(dateStr), "MMM d");
|
const [year, month, day] = dateStr.split("-").map(Number);
|
||||||
|
return format(new Date(year, month - 1, day), "MMM d");
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -56,3 +59,28 @@ export function localDateTimeToUtcIso(value: string | null | undefined): string
|
||||||
if (isNaN(date.getTime())) return null;
|
if (isNaN(date.getTime())) return null;
|
||||||
return date.toISOString();
|
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}`;
|
||||||
|
}
|
||||||
|
|
|
||||||
87
app/lib/error-reporting.ts
Normal file
87
app/lib/error-reporting.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
162
app/lib/fifa-2026-bracket.ts
Normal file
162
app/lib/fifa-2026-bracket.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
506
app/lib/fifa-2026-third-place-allocation.ts
Normal file
506
app/lib/fifa-2026-third-place-allocation.ts
Normal file
|
|
@ -0,0 +1,506 @@
|
||||||
|
// 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",
|
||||||
|
};
|
||||||
194
app/lib/llws-bracket.ts
Normal file
194
app/lib/llws-bracket.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
/**
|
||||||
|
* 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,9 +1,15 @@
|
||||||
/**
|
/**
|
||||||
* Normalize a team name for fuzzy matching across data sources.
|
* Normalize a team/participant name for fuzzy matching across data sources.
|
||||||
* Lowercases, trims, and collapses whitespace.
|
* Folds accents (so "Stéfanos Tsitsipás" matches a plain "Stefanos Tsitsipas"),
|
||||||
|
* lowercases, trims, and collapses whitespace.
|
||||||
*/
|
*/
|
||||||
export function normalizeTeamName(name: string): string {
|
export function normalizeTeamName(name: string): string {
|
||||||
return name.toLowerCase().trim().replace(/\s+/g, " ");
|
return name
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[̀-ͯ]/g, "") // strip combining diacritical marks
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { AFL_10, getScoringRoundType } from "~/lib/bracket-templates";
|
import { AFL_10, getScoringRoundType } from "~/lib/bracket-templates";
|
||||||
import { calculateFantasyPoints, calculateAveragedPoints, type ScoringRules } from "../scoring-rules";
|
import { calculateFantasyPoints, calculateAveragedPoints, calculateBracketPoints, type ScoringRules } from "../scoring-rules";
|
||||||
|
import { getBracketEntryFloor } from "../scoring-calculator";
|
||||||
|
|
||||||
const DEFAULT_SCORING: ScoringRules = {
|
const DEFAULT_SCORING: ScoringRules = {
|
||||||
pointsFor1st: 100,
|
pointsFor1st: 100,
|
||||||
|
|
@ -206,3 +207,69 @@ 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
273
app/models/__tests__/afl-semifinal-pairing.test.ts
Normal file
273
app/models/__tests__/afl-semifinal-pairing.test.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
/**
|
||||||
|
* 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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
271
app/models/__tests__/afl-wildcard-advancement.test.ts
Normal file
271
app/models/__tests__/afl-wildcard-advancement.test.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
||||||
|
/**
|
||||||
|
* 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
243
app/models/__tests__/bracket-entry-floors.test.ts
Normal file
243
app/models/__tests__/bracket-entry-floors.test.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
92
app/models/__tests__/bracket-template-lookup.test.ts
Normal file
92
app/models/__tests__/bracket-template-lookup.test.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
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,6 +1,17 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { computeStage3ExitQP } from "../cs2-major-stage";
|
|
||||||
import { calculateSplitQualifyingPoints } from "../qualifying-points";
|
// 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";
|
||||||
|
|
||||||
function makeStage3QPConfig(): Map<number, number> {
|
function makeStage3QPConfig(): Map<number, number> {
|
||||||
const config = new Map<number, number>();
|
const config = new Map<number, number>();
|
||||||
|
|
@ -257,3 +268,57 @@ describe("Champions Stage floor QP calculation", () => {
|
||||||
expect(floorQP).toBeCloseTo((10 + 8 + 6 + 4) / 4); // = 7
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
87
app/models/__tests__/futures-odds-simulator.test.ts
Normal file
87
app/models/__tests__/futures-odds-simulator.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
481
app/models/__tests__/llws-20-bracket.test.ts
Normal file
481
app/models/__tests__/llws-20-bracket.test.ts
Normal file
|
|
@ -0,0 +1,481 @@
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
113
app/models/__tests__/populate-bracket-from-draw.test.ts
Normal file
113
app/models/__tests__/populate-bracket-from-draw.test.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
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,12 +190,26 @@ describe("processMatchResult", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("AFL Wildcard Round: loser=0, winner gets T5 floor (feeds into Elimination Finals = scoring)", async () => {
|
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.
|
||||||
const { db, insertedRows } = makeDb();
|
const { db, insertedRows } = makeDb();
|
||||||
await processMatchResult({ ...BASE, bracketTemplateId: "afl_10", round: "Wildcard Round", isScoring: false }, db);
|
await processMatchResult({ ...BASE, bracketTemplateId: "afl_10", round: "Wildcard Round", isScoring: false }, db);
|
||||||
expect(insertedRows).toHaveLength(2);
|
expect(insertedRows).toHaveLength(2);
|
||||||
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false });
|
expect(insertedRows[0]).toMatchObject({ participantId: "loser-1", finalPosition: 0, isPartialScore: false });
|
||||||
expect(insertedRows[1]).toMatchObject({ participantId: "winner-1", finalPosition: 5, isPartialScore: true });
|
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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("NBA Play-In loserAdvances=true (7v8 game)", () => {
|
describe("NBA Play-In loserAdvances=true (7v8 game)", () => {
|
||||||
|
|
@ -305,6 +319,22 @@ describe("processMatchResult", () => {
|
||||||
expect(updateProbabilitiesAfterResult).toHaveBeenCalledWith("ss-1", true);
|
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 () => {
|
it("does not throw even if probability update fails", async () => {
|
||||||
(updateProbabilitiesAfterResult as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
(updateProbabilitiesAfterResult as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||||
new Error("network error")
|
new Error("network error")
|
||||||
|
|
@ -399,6 +429,7 @@ describe("isLoserNotifiable", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
describe("processPlayoffEvent - NBA Play-In Round 1 loserAdvances fix", () => {
|
describe("processPlayoffEvent - NBA Play-In Round 1 loserAdvances fix", () => {
|
||||||
function makePlayInDb(matches: object[]) {
|
function makePlayInDb(matches: object[]) {
|
||||||
const insertedRows: Array<{ participantId: string; finalPosition: number; isPartialScore: boolean }> = [];
|
const insertedRows: Array<{ participantId: string; finalPosition: number; isPartialScore: boolean }> = [];
|
||||||
|
|
|
||||||
251
app/models/__tests__/qualifying-bracket-scoring.test.ts
Normal file
251
app/models/__tests__/qualifying-bracket-scoring.test.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
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,9 +2,47 @@ import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
calculateSplitQualifyingPoints,
|
calculateSplitQualifyingPoints,
|
||||||
DEFAULT_QP_VALUES,
|
DEFAULT_QP_VALUES,
|
||||||
hasProcessedQualifyingPlacement,
|
diffChangedQualifyingPoints,
|
||||||
} from "../qualifying-points";
|
} 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("Qualifying Points Configuration", () => {
|
||||||
describe("DEFAULT_QP_VALUES", () => {
|
describe("DEFAULT_QP_VALUES", () => {
|
||||||
it("should have 16 placement values", () => {
|
it("should have 16 placement values", () => {
|
||||||
|
|
@ -271,52 +309,6 @@ describe("Qualifying Points Configuration", () => {
|
||||||
expect(totalQP2).toBe(20); // Should have 20 QP (1st place)
|
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", () => {
|
describe("Scoring Workflow", () => {
|
||||||
|
|
|
||||||
143
app/models/__tests__/scoring-calculator-qp-notify.test.ts
Normal file
143
app/models/__tests__/scoring-calculator-qp-notify.test.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
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,7 +200,21 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("processQualifyingEvent", () => {
|
describe("processQualifyingEvent", () => {
|
||||||
function makeProcessQPMockDb(eventResults: Array<Record<string, unknown>>) {
|
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;
|
||||||
const setCalls: unknown[] = [];
|
const setCalls: unknown[] = [];
|
||||||
const updateChain = {
|
const updateChain = {
|
||||||
set: (values: unknown) => {
|
set: (values: unknown) => {
|
||||||
|
|
@ -217,6 +231,8 @@ describe("Qualifying Points - Fantasy Scoring Integration", () => {
|
||||||
id: "event-1",
|
id: "event-1",
|
||||||
sportsSeasonId: "sports-season-1",
|
sportsSeasonId: "sports-season-1",
|
||||||
isQualifyingEvent: true,
|
isQualifyingEvent: true,
|
||||||
|
tournamentId,
|
||||||
|
bracketTemplateId,
|
||||||
sportsSeason: { majorsCompleted: 1 },
|
sportsSeason: { majorsCompleted: 1 },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
@ -238,6 +254,23 @@ 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,
|
update: () => updateChain,
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
|
|
@ -273,6 +306,123 @@ 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 () => {
|
it("does not increment majorsCompleted when reprocessing a placed zero-QP result", async () => {
|
||||||
const { db, setCalls } = makeProcessQPMockDb([
|
const { db, setCalls } = makeProcessQPMockDb([
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ vi.mock("../qualifying-points", async (importOriginal) => {
|
||||||
import { deleteScoringEvent } from "../scoring-event";
|
import { deleteScoringEvent } from "../scoring-event";
|
||||||
|
|
||||||
describe("deleteScoringEvent", () => {
|
describe("deleteScoringEvent", () => {
|
||||||
it("decrements majorsCompleted for a processed zero-QP qualifying event", async () => {
|
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.
|
||||||
const setCalls: unknown[] = [];
|
const setCalls: unknown[] = [];
|
||||||
const deleteChain = { where: vi.fn().mockResolvedValue(undefined) };
|
const deleteChain = { where: vi.fn().mockResolvedValue(undefined) };
|
||||||
const updateChain = {
|
const updateChain = {
|
||||||
|
|
@ -43,12 +46,6 @@ describe("deleteScoringEvent", () => {
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
sportsSeasons: {
|
|
||||||
findFirst: vi.fn().mockResolvedValue({
|
|
||||||
id: "sports-season-1",
|
|
||||||
majorsCompleted: 1,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
transaction: vi.fn(async (callback) => callback(db)),
|
transaction: vi.fn(async (callback) => callback(db)),
|
||||||
delete: vi.fn(() => deleteChain),
|
delete: vi.fn(() => deleteChain),
|
||||||
|
|
@ -57,10 +54,125 @@ describe("deleteScoringEvent", () => {
|
||||||
|
|
||||||
await deleteScoringEvent("event-1", db);
|
await deleteScoringEvent("event-1", db);
|
||||||
|
|
||||||
expect(setCalls).toEqual(
|
expect(setCalls).not.toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ majorsCompleted: 0 }),
|
expect.objectContaining({ majorsCompleted: expect.anything() }),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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,11 +1,23 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
const mockFindMany = vi.fn();
|
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 = {
|
const mockDb = {
|
||||||
query: {
|
query: {
|
||||||
scoringEvents: { findMany: mockFindMany },
|
scoringEvents: { findMany: mockFindMany, findFirst: mockFindFirst },
|
||||||
},
|
},
|
||||||
|
update: mockUpdate,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("~/database/context", () => ({
|
vi.mock("~/database/context", () => ({
|
||||||
|
|
@ -14,8 +26,10 @@ vi.mock("~/database/context", () => ({
|
||||||
|
|
||||||
vi.mock("~/database/schema", () => ({
|
vi.mock("~/database/schema", () => ({
|
||||||
scoringEvents: {
|
scoringEvents: {
|
||||||
|
id: "se.id",
|
||||||
sportsSeasonId: "se.sports_season_id",
|
sportsSeasonId: "se.sports_season_id",
|
||||||
tournamentId: "se.tournament_id",
|
tournamentId: "se.tournament_id",
|
||||||
|
isPrimary: "se.is_primary",
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -23,11 +37,14 @@ vi.mock("drizzle-orm", () => ({
|
||||||
eq: (col: unknown, val: unknown) => ({ type: "eq", col, val }),
|
eq: (col: unknown, val: unknown) => ({ type: "eq", col, val }),
|
||||||
and: (...args: unknown[]) => ({ type: "and", args }),
|
and: (...args: unknown[]) => ({ type: "and", args }),
|
||||||
isNotNull: (col: unknown) => ({ type: "isNotNull", col }),
|
isNotNull: (col: unknown) => ({ type: "isNotNull", col }),
|
||||||
|
asc: (col: unknown) => ({ type: "asc", col }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getTournamentsBySportsSeason,
|
getTournamentsBySportsSeason,
|
||||||
getSportsSeasonsByTournament,
|
getSportsSeasonsByTournament,
|
||||||
|
ensurePrimaryEvent,
|
||||||
|
setPrimaryEvent,
|
||||||
} from "../scoring-event";
|
} from "../scoring-event";
|
||||||
|
|
||||||
const SEASON_ID = "ss-1";
|
const SEASON_ID = "ss-1";
|
||||||
|
|
@ -39,6 +56,7 @@ const mockTournamentB = { id: TOURNAMENT_ID_B, name: "US Open", year: 2026 };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
updateCalls.length = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("getTournamentsBySportsSeason", () => {
|
describe("getTournamentsBySportsSeason", () => {
|
||||||
|
|
@ -116,3 +134,37 @@ 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
192
app/models/__tests__/season-races.test.ts
Normal file
192
app/models/__tests__/season-races.test.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
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,4 +71,90 @@ describe("simulator input model", () => {
|
||||||
expect(byParticipant.get("generated-rating")?.rating).toBeNull();
|
expect(byParticipant.get("generated-rating")?.rating).toBeNull();
|
||||||
expect(byParticipant.get("legacy-direct-rating")?.rating).toBe(27.5);
|
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,7 +27,11 @@ vi.mock("drizzle-orm", () => ({
|
||||||
asc: (col: unknown) => ({ type: "asc", col }),
|
asc: (col: unknown) => ({ type: "asc", col }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { findAllSportsSeasons, findDraftableSportsSeasons } from "../sports-season";
|
import {
|
||||||
|
findAllSportsSeasons,
|
||||||
|
findDraftableSportsSeasons,
|
||||||
|
findDraftScheduleForHorizon,
|
||||||
|
} from "../sports-season";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
@ -125,3 +129,58 @@ 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,6 +80,9 @@ function makeDb(
|
||||||
},
|
},
|
||||||
scoringEvents: {
|
scoringEvents: {
|
||||||
findFirst: vi.fn().mockResolvedValue({ bracketTemplateId: null }),
|
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: {
|
seasonParticipantResults: {
|
||||||
findMany: vi.fn().mockResolvedValue(seasonResults),
|
findMany: vi.fn().mockResolvedValue(seasonResults),
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import {
|
||||||
findTournamentBySportNameYear,
|
findTournamentBySportNameYear,
|
||||||
upsertTournament,
|
upsertTournament,
|
||||||
updateTournamentStatus,
|
updateTournamentStatus,
|
||||||
|
deleteTournament,
|
||||||
} from "../tournament";
|
} from "../tournament";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
|
|
||||||
|
|
@ -172,3 +173,26 @@ describe("updateTournamentStatus", () => {
|
||||||
expect(result.status).toBe("in_progress");
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
66
app/models/bracket-template.ts
Normal file
66
app/models/bracket-template.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
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,7 +15,10 @@
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import { cs2MajorStageResults, seasonParticipants, eventResults } from "~/database/schema";
|
import { cs2MajorStageResults, seasonParticipants, eventResults } from "~/database/schema";
|
||||||
import { eq, and, sql } from "drizzle-orm";
|
import { eq, and, sql } from "drizzle-orm";
|
||||||
import { getQPConfig, recalculateParticipantQP, calculateSplitQualifyingPoints } from "~/models/qualifying-points";
|
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";
|
||||||
|
|
||||||
export interface Cs2StageResult {
|
export interface Cs2StageResult {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -126,14 +129,57 @@ export async function upsertCs2StageAssignments(
|
||||||
* Called when the admin resets the event setup.
|
* Called when the admin resets the event setup.
|
||||||
*/
|
*/
|
||||||
export async function clearCs2StageAssignments(
|
export async function clearCs2StageAssignments(
|
||||||
scoringEventId: string
|
scoringEventId: string,
|
||||||
|
providedDb?: ReturnType<typeof database>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = database();
|
const db = providedDb || database();
|
||||||
await db
|
await db
|
||||||
.delete(cs2MajorStageResults)
|
.delete(cs2MajorStageResults)
|
||||||
.where(eq(cs2MajorStageResults.scoringEventId, scoringEventId));
|
.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.
|
* Mark teams as eliminated from a specific stage.
|
||||||
* Each elimination entry specifies which stage the team was eliminated at —
|
* Each elimination entry specifies which stage the team was eliminated at —
|
||||||
|
|
@ -394,34 +440,30 @@ export async function assignCs2EliminationQP(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
// 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.
|
||||||
await Promise.all(
|
const existingRows = await db.query.eventResults.findMany({
|
||||||
[...resultsByParticipant.entries()].map(([seasonParticipantId, { qp, placement }]) =>
|
where: eq(eventResults.scoringEventId, scoringEventId),
|
||||||
db
|
});
|
||||||
.insert(eventResults)
|
const existingQPBySPId = new Map<string, number>(
|
||||||
.values({
|
existingRows
|
||||||
scoringEventId,
|
.filter((r) => r.qualifyingPointsAwarded !== null)
|
||||||
seasonParticipantId,
|
.map((r) => [r.seasonParticipantId, parseFloat(r.qualifyingPointsAwarded as string)])
|
||||||
qualifyingPointsAwarded: qp.toString(),
|
|
||||||
placement,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: [eventResults.scoringEventId, eventResults.seasonParticipantId],
|
|
||||||
set: {
|
|
||||||
qualifyingPointsAwarded: qp.toString(),
|
|
||||||
placement,
|
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await Promise.all(
|
await writeEventResultsQP(scoringEventId, sportsSeasonId, resultsByParticipant, db);
|
||||||
[...resultsByParticipant.keys()].map((seasonParticipantId) =>
|
|
||||||
recalculateParticipantQP(seasonParticipantId, sportsSeasonId)
|
const changedIds = new Set<string>(
|
||||||
)
|
[...resultsByParticipant.entries()]
|
||||||
|
.filter(([id, { qp }]) => existingQPBySPId.get(id) !== qp)
|
||||||
|
.map(([id]) => id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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,6 +7,7 @@ import {
|
||||||
calculateBracketPoints,
|
calculateBracketPoints,
|
||||||
calculateSharedPlacementPoints,
|
calculateSharedPlacementPoints,
|
||||||
} from "./scoring-rules";
|
} from "./scoring-rules";
|
||||||
|
import { getBracketTemplateIdsForSportsSeasons } from "./bracket-template";
|
||||||
|
|
||||||
export async function createDraftPick(data: {
|
export async function createDraftPick(data: {
|
||||||
seasonId: string;
|
seasonId: string;
|
||||||
|
|
@ -175,18 +176,10 @@ export async function getDraftedParticipantsWithPoints(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch-fetch bracket template IDs (one per sports season)
|
// Batch-fetch bracket template IDs (one per sports season)
|
||||||
const bracketTemplateMap = new Map<string, string | null>();
|
const bracketTemplateMap =
|
||||||
if (bracketSeasonIds.size > 0) {
|
bracketSeasonIds.size > 0
|
||||||
const events = await db.query.scoringEvents.findMany({
|
? await getBracketTemplateIdsForSportsSeasons([...bracketSeasonIds], db)
|
||||||
where: inArray(schema.scoringEvents.sportsSeasonId, [...bracketSeasonIds]),
|
: new Map<string, string | null>();
|
||||||
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
|
// Batch-fetch QP totals for qualifying_points participants
|
||||||
const qpMap = new Map<string, number>(); // participantId → totalQP
|
const qpMap = new Map<string, number>(); // participantId → totalQP
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import { seasonParticipantExpectedValues, seasonParticipants, seasonParticipantSimulatorInputs } from "~/database/schema";
|
import { seasonParticipantExpectedValues, seasonParticipants } from "~/database/schema";
|
||||||
import { eq, and, count, inArray, sql } from "drizzle-orm";
|
import { eq, and, count, sql } from "drizzle-orm";
|
||||||
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
|
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
|
||||||
import { calculateEV, normalizeProbabilities, calculateReplacementLevel, calculateVORP } from "~/services/ev-calculator";
|
import { calculateEV, normalizeProbabilities, calculateReplacementLevel, calculateVORP } from "~/services/ev-calculator";
|
||||||
import { batchSaveParticipantSimulatorSourceOdds, batchUpsertParticipantSimulatorInputs } from "~/models/simulator";
|
import { batchUpsertParticipantSimulatorInputs } from "~/models/simulator";
|
||||||
|
|
||||||
export type ProbabilitySource = "manual" | "futures_odds" | "elo_simulation" | "performance_model";
|
export type ProbabilitySource = "manual" | "futures_odds" | "elo_simulation" | "performance_model";
|
||||||
|
|
||||||
|
|
@ -366,95 +366,6 @@ export async function batchUpsertParticipantEVs(
|
||||||
return results;
|
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.
|
* Persist raw Elo ratings (and optional world rankings) for a batch of seasonParticipants.
|
||||||
* Used by Elo-based simulators like snooker_bracket and darts_bracket.
|
* Used by Elo-based simulators like snooker_bracket and darts_bracket.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
|
|
||||||
|
|
@ -95,14 +95,42 @@ export async function deleteParticipantResult(id: string): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteParticipantResultsBySportsSeasonId(
|
export async function deleteParticipantResultsBySportsSeasonId(
|
||||||
sportsSeasonId: string
|
sportsSeasonId: string,
|
||||||
|
providedDb?: ReturnType<typeof database>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = database();
|
const db = providedDb || database();
|
||||||
await db
|
await db
|
||||||
.delete(schema.seasonParticipantResults)
|
.delete(schema.seasonParticipantResults)
|
||||||
.where(eq(schema.seasonParticipantResults.sportsSeasonId, sportsSeasonId));
|
.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
|
* Set result for a participant in a sports season
|
||||||
* Points are calculated on-demand based on each fantasy league's scoring rules
|
* Points are calculated on-demand based on each fantasy league's scoring rules
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,19 @@ import {
|
||||||
getBracketTemplate,
|
getBracketTemplate,
|
||||||
buildNCAA68SlotMap,
|
buildNCAA68SlotMap,
|
||||||
matchIndexForSeedSlot,
|
matchIndexForSeedSlot,
|
||||||
|
llwsMatchNumber,
|
||||||
|
llwsSideAndLocal,
|
||||||
STANDARD_BRACKET_SEEDING,
|
STANDARD_BRACKET_SEEDING,
|
||||||
} from "~/lib/bracket-templates";
|
} 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 PlayoffMatch = typeof schema.playoffMatches.$inferSelect;
|
||||||
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
|
export type NewPlayoffMatch = typeof schema.playoffMatches.$inferInsert;
|
||||||
|
|
@ -142,6 +153,108 @@ export async function setMatchWinner(
|
||||||
return match;
|
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
|
* Generate a standard single elimination bracket structure
|
||||||
*/
|
*/
|
||||||
|
|
@ -365,6 +478,11 @@ export async function generateBracketFromTemplate(
|
||||||
return await generateNBA20Bracket(eventId, template, participantIds);
|
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[] = [];
|
const matches: NewPlayoffMatch[] = [];
|
||||||
|
|
||||||
// Generate matches for each round in the template
|
// Generate matches for each round in the template
|
||||||
|
|
@ -628,9 +746,11 @@ async function generateNFL14Bracket(
|
||||||
* Structure:
|
* Structure:
|
||||||
* - Wildcard Round: 7v10, 8v9
|
* - Wildcard Round: 7v10, 8v9
|
||||||
* - Qualifying Finals: 1v4, 2v3 (winners get bye to Preliminary Finals, losers to Semi-Finals)
|
* - Qualifying Finals: 1v4, 2v3 (winners get bye to Preliminary Finals, losers to Semi-Finals)
|
||||||
* - Elimination Finals: 5v8, 6v7 (where 7 and 8 are wildcard winners)
|
* - Elimination Finals: 5 and 6 host the two Wildcard winners, re-seeded by ladder
|
||||||
* - Semi-Finals: QF losers vs EF winners
|
* position — 5th draws the lower-ranked winner, 6th the higher-ranked one
|
||||||
* - Preliminary Finals: QF winners vs SF winners
|
* - 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)
|
||||||
* - Grand Final: PF winners
|
* - Grand Final: PF winners
|
||||||
*/
|
*/
|
||||||
async function generateAFL10Bracket(
|
async function generateAFL10Bracket(
|
||||||
|
|
@ -682,14 +802,16 @@ async function generateAFL10Bracket(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Elimination Finals: 5th vs TBD (wildcard winner), 6th vs TBD (wildcard winner)
|
// 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.
|
||||||
const eliminationSeeding = [
|
const eliminationSeeding = [
|
||||||
{ higher: 4, wildcard: 2 }, // #5 (index 4) vs Wildcard Match 2 winner
|
{ higher: 4, opponent: "lower-ranked WC winner" }, // #5 (index 4)
|
||||||
{ higher: 5, wildcard: 1 }, // #6 (index 5) vs Wildcard Match 1 winner
|
{ higher: 5, opponent: "higher-ranked WC winner" }, // #6 (index 5)
|
||||||
];
|
];
|
||||||
|
|
||||||
for (let i = 0; i < eliminationSeeding.length; i++) {
|
for (let i = 0; i < eliminationSeeding.length; i++) {
|
||||||
const { higher, wildcard } = eliminationSeeding[i];
|
const { higher, opponent } = eliminationSeeding[i];
|
||||||
matches.push({
|
matches.push({
|
||||||
scoringEventId: eventId,
|
scoringEventId: eventId,
|
||||||
round: "Elimination Finals",
|
round: "Elimination Finals",
|
||||||
|
|
@ -699,11 +821,11 @@ async function generateAFL10Bracket(
|
||||||
isComplete: false,
|
isComplete: false,
|
||||||
isScoring: true, // Losers share 7th-8th
|
isScoring: true, // Losers share 7th-8th
|
||||||
templateRound: "Elimination Finals",
|
templateRound: "Elimination Finals",
|
||||||
seedInfo: participantIds ? `${higher + 1} vs WC${wildcard}` : null,
|
seedInfo: participantIds ? `${higher + 1} vs ${opponent}` : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Semi-Finals: QF losers vs EF winners (TBD vs TBD)
|
// Semi-Finals: SF n = QF n loser vs EF n winner (TBD vs TBD)
|
||||||
for (let i = 0; i < 2; i++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
matches.push({
|
matches.push({
|
||||||
scoringEventId: eventId,
|
scoringEventId: eventId,
|
||||||
|
|
@ -749,15 +871,257 @@ async function generateAFL10Bracket(
|
||||||
return await createManyPlayoffMatches(matches);
|
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
|
* AFL-specific advancement logic for the complex double-chance system
|
||||||
* Phase 3.3: Handles both winners and losers advancing to different rounds
|
* Phase 3.3: Handles both winners and losers advancing to different rounds
|
||||||
*
|
*
|
||||||
* Advancement rules:
|
* Advancement rules:
|
||||||
* - Wildcard Round: Winner → Elimination Finals
|
* - Wildcard Round: Winner → Elimination Finals (re-seeded by ladder position)
|
||||||
* - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals
|
* - Qualifying Finals: Winner → Preliminary Finals, Loser → Semi-Finals (QF n → PF n, SF n)
|
||||||
* - Elimination Finals: Winner → Semi-Finals
|
* - Elimination Finals: Winner → Semi-Finals (EF n → SF n, a fixed pathway)
|
||||||
* - Semi-Finals: Winner → Preliminary Finals
|
* - Semi-Finals: Winner → Preliminary Finals (SF n crosses over: SF1 → PF2, SF2 → PF1)
|
||||||
* - Preliminary Finals: Winner → Grand Final
|
* - Preliminary Finals: Winner → Grand Final
|
||||||
*/
|
*/
|
||||||
async function advanceAFLWinner(
|
async function advanceAFLWinner(
|
||||||
|
|
@ -767,18 +1131,10 @@ async function advanceAFLWinner(
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const eventId = match.scoringEventId;
|
const eventId = match.scoringEventId;
|
||||||
|
|
||||||
// Wildcard Round: Winner advances to Elimination Finals
|
// Wildcard Round: winners are re-seeded into the Elimination Finals by ladder
|
||||||
|
// position, so every result re-resolves both slots.
|
||||||
if (match.round === "Wildcard Round") {
|
if (match.round === "Wildcard Round") {
|
||||||
// Wildcard Match 1 winner → EF Match 2, participant2Id
|
await reseedAflEliminationFinals(eventId, { matchId: match.id, winnerId });
|
||||||
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -806,18 +1162,11 @@ async function advanceAFLWinner(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Elimination Finals: Winner → Semi-Finals
|
// 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.
|
||||||
if (match.round === "Elimination Finals") {
|
if (match.round === "Elimination Finals") {
|
||||||
// EF Match 1 winner → SF2 participant2
|
await reseedAflSemiFinals(eventId, { matchId: match.id, winnerId });
|
||||||
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -878,6 +1227,15 @@ export async function advanceWinnerTemplate(
|
||||||
return await advanceNBAPlayInWinner(match, winnerId, loserId);
|
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
|
// Special handling for AFL 10 double-chance system
|
||||||
// Phase 3.3: AFL has complex winner/loser advancement rules
|
// Phase 3.3: AFL has complex winner/loser advancement rules
|
||||||
if (template.id === "afl_10") {
|
if (template.id === "afl_10") {
|
||||||
|
|
@ -1325,6 +1683,12 @@ export function doesLoserAdvance(
|
||||||
if (templateId === "afl_10" && round === "Qualifying Finals") {
|
if (templateId === "afl_10" && round === "Qualifying Finals") {
|
||||||
return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1436,3 +1800,140 @@ async function advanceNBAPlayInWinner(
|
||||||
throw new Error(`Unknown Play-In Round 2 match number: ${match.matchNumber}`);
|
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,6 +33,33 @@ export function roundQualifyingPoints(points: number): number {
|
||||||
return Math.round((points + Number.EPSILON) * 100) / 100;
|
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(
|
export function calculateSplitQualifyingPoints(
|
||||||
placement: number,
|
placement: number,
|
||||||
tieCount: number,
|
tieCount: number,
|
||||||
|
|
@ -48,21 +75,6 @@ export function calculateSplitQualifyingPoints(
|
||||||
return roundQualifyingPoints(totalQP / tieCount);
|
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
|
* Initialize default qualifying point configuration for a sports season
|
||||||
*/
|
*/
|
||||||
|
|
@ -283,6 +295,57 @@ export async function recalculateParticipantQP(
|
||||||
return { totalQP, eventsScored };
|
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
|
* Recalculate eventsScored for a participant by counting unique events with QP awarded
|
||||||
* @deprecated Use recalculateParticipantQP instead for more accurate recalculation
|
* @deprecated Use recalculateParticipantQP instead for more accurate recalculation
|
||||||
|
|
|
||||||
|
|
@ -9,20 +9,23 @@ import {
|
||||||
} from "./scoring-rules";
|
} from "./scoring-rules";
|
||||||
import { getSeasonResults } from "./participant-season-result";
|
import { getSeasonResults } from "./participant-season-result";
|
||||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||||
import { sendStandingsUpdateNotification, type ScoredMatch } from "~/services/discord";
|
import { sendStandingsUpdateNotification, type ScoredMatch, type EliminatedTeam } from "~/services/discord";
|
||||||
import { BRACKET_TEMPLATES } from "~/lib/bracket-templates";
|
import { notifyQualifyingPointsUpdate } from "~/services/qualifying-points-discord.server";
|
||||||
|
import { BRACKET_TEMPLATES, type BracketRound } from "~/lib/bracket-templates";
|
||||||
import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-match";
|
import { doesLoserAdvance, findPlayoffMatchesByEventId } from "~/models/playoff-match";
|
||||||
import { getUserDisplayName } from "~/models/user";
|
import { getUserDisplayName } from "~/models/user";
|
||||||
import { findDiscordIdsByUserIds } from "~/models/account";
|
import { findDiscordIdsByUserIds } from "~/models/account";
|
||||||
import { createDailySnapshot } from "~/models/standings";
|
import { createDailySnapshot } from "~/models/standings";
|
||||||
|
import { getBracketTemplateIdForSportsSeason } from "~/models/bracket-template";
|
||||||
import { recordMatchScoreEvents } from "~/models/team-score-events";
|
import { recordMatchScoreEvents } from "~/models/team-score-events";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { getEventResults } from "./event-result";
|
import { getEventResults } from "./event-result";
|
||||||
import {
|
import {
|
||||||
calculateSplitQualifyingPoints,
|
calculateSplitQualifyingPoints,
|
||||||
|
diffChangedQualifyingPoints,
|
||||||
getQPConfig,
|
getQPConfig,
|
||||||
hasProcessedQualifyingPlacement,
|
|
||||||
recalculateParticipantQP,
|
recalculateParticipantQP,
|
||||||
|
writeEventResultsQP,
|
||||||
getQPStandings,
|
getQPStandings,
|
||||||
updateFinalRankings,
|
updateFinalRankings,
|
||||||
} from "./qualifying-points";
|
} from "./qualifying-points";
|
||||||
|
|
@ -111,31 +114,169 @@ const TEMPLATE_ROUND_CONFIG: Record<string, Record<string, RoundScoringConfig>>
|
||||||
// 3rd place game finalizes both positions distinctly.
|
// 3rd place game finalizes both positions distinctly.
|
||||||
"Third Place Game": { loserPosition: 4, loserIsPartial: false, winnerFloor: null, winnerPosition: 3 },
|
"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 true if a non-scoring round's winners are entering the first scoring round
|
* Returns the floor position that winners of a NON-scoring round should bank, or null
|
||||||
* (i.e., they've guaranteed a top-8 fantasy placement and should receive a T5–T8 floor).
|
* to bank nothing.
|
||||||
*
|
*
|
||||||
* For multi-round pre-bracket sequences like NCAA (Round of 64 → Round of 32 →
|
* Default: winners entering the first scoring round have guaranteed a top-8 fantasy
|
||||||
* Sweet Sixteen → Elite Eight), only Sweet Sixteen winners are entering the scoring
|
* placement and receive a T5–T8 floor (5); everyone else gets nothing yet. For
|
||||||
* bracket — Round of 64 and Round of 32 winners should not receive any floor yet.
|
* 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.
|
||||||
*
|
*
|
||||||
* Falls back to true when template/round info is unavailable to preserve legacy behavior.
|
* 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.
|
||||||
*/
|
*/
|
||||||
function doesNonScoringRoundFeedIntoScoringRound(
|
function nonScoringWinnerFloorFor(
|
||||||
round: string,
|
round: string,
|
||||||
bracketTemplateId: string | null | undefined
|
bracketTemplateId: string | null | undefined
|
||||||
): boolean {
|
): number | null {
|
||||||
if (!bracketTemplateId) return true; // Legacy: preserve old behavior
|
if (!bracketTemplateId) return 5; // Legacy: preserve old behavior
|
||||||
const template = BRACKET_TEMPLATES[bracketTemplateId];
|
const template = BRACKET_TEMPLATES[bracketTemplateId];
|
||||||
if (!template) return true; // Unknown template: preserve old behavior
|
if (!template) return 5; // Unknown template: preserve old behavior
|
||||||
const currentRound = template.rounds.find((r) => r.name === round);
|
const currentRound = template.rounds.find((r) => r.name === round);
|
||||||
if (!currentRound) return true; // Unknown round: preserve old behavior
|
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;
|
||||||
|
}
|
||||||
const nextRoundName = currentRound.feedsInto;
|
const nextRoundName = currentRound.feedsInto;
|
||||||
if (!nextRoundName) return false; // No next round (shouldn't happen for non-scoring)
|
if (!nextRoundName) return null; // No next round (shouldn't happen for non-scoring)
|
||||||
const nextRound = template.rounds.find((r) => r.name === nextRoundName);
|
const nextRound = template.rounds.find((r) => r.name === nextRoundName);
|
||||||
return nextRound?.isScoring === true;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -143,7 +284,7 @@ function doesNonScoringRoundFeedIntoScoringRound(
|
||||||
* overrides before falling back to the standard ROUND_CONFIG.
|
* overrides before falling back to the standard ROUND_CONFIG.
|
||||||
* Returns null for unrecognized rounds.
|
* Returns null for unrecognized rounds.
|
||||||
*/
|
*/
|
||||||
function getRoundConfig(
|
export function getRoundConfig(
|
||||||
round: string,
|
round: string,
|
||||||
bracketTemplateId?: string | null
|
bracketTemplateId?: string | null
|
||||||
): RoundScoringConfig | null {
|
): RoundScoringConfig | null {
|
||||||
|
|
@ -269,19 +410,18 @@ export async function processPlayoffEvent(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isScoring) {
|
if (!isScoring) {
|
||||||
// Non-scoring (pre-bracket) round: losers are permanently eliminated (0 pts).
|
// Non-scoring round: losers are permanently eliminated (0 pts) unless they
|
||||||
// Winners only bank a provisional T5–T8 floor if they're entering the first
|
// advance (double-elimination winners-bracket losers). Winners bank a
|
||||||
// scoring round (i.e., guaranteed top-8). For multi-round pre-bracket sequences
|
// provisional floor only when this round guarantees them one — see
|
||||||
// like NCAA (R64 → R32 → Sweet 16 → Elite Eight), only Sweet 16 winners should
|
// nonScoringWinnerFloorFor for how that is derived per template.
|
||||||
// receive floor points — R64 and R32 winners are not yet guaranteed top-8.
|
const winnerFloor = nonScoringWinnerFloorFor(round, event.bracketTemplateId);
|
||||||
const awardFloor = doesNonScoringRoundFeedIntoScoringRound(round, event.bracketTemplateId);
|
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
const loserAdvances = doesLoserAdvance(round, match.matchNumber, event.bracketTemplateId ?? "");
|
const loserAdvances = doesLoserAdvance(round, match.matchNumber, event.bracketTemplateId ?? "");
|
||||||
if (match.loserId && !loserAdvances) {
|
if (match.loserId && !loserAdvances) {
|
||||||
await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db);
|
await upsertParticipantResult(match.loserId, event.sportsSeasonId, 0, db);
|
||||||
}
|
}
|
||||||
if (match.winnerId && awardFloor) {
|
if (match.winnerId && winnerFloor !== null) {
|
||||||
await upsertParticipantResult(match.winnerId, event.sportsSeasonId, 5, db, true);
|
await upsertParticipantResult(match.winnerId, event.sportsSeasonId, winnerFloor, db, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -341,7 +481,7 @@ export async function processPlayoffEvent(
|
||||||
// Progressive floor scoring: assign guaranteed minimum points to winners.
|
// Progressive floor scoring: assign guaranteed minimum points to winners.
|
||||||
// For Finals (winnerFloor=null) getGuaranteedMinimumPosition returns null — the
|
// For Finals (winnerFloor=null) getGuaranteedMinimumPosition returns null — the
|
||||||
// winner is already finalized as 1st above. For non-scoring rounds it also
|
// winner is already finalized as 1st above. For non-scoring rounds it also
|
||||||
// returns null (winners were given floor 5 inline above).
|
// returns null; those winners were given their floor inline above.
|
||||||
const guaranteedMinimum = getGuaranteedMinimumPosition(
|
const guaranteedMinimum = getGuaranteedMinimumPosition(
|
||||||
round,
|
round,
|
||||||
event.bracketTemplateId,
|
event.bracketTemplateId,
|
||||||
|
|
@ -407,6 +547,19 @@ export async function processMatchResult(
|
||||||
/** When set, Discord notification only shows this match (not all completed matches for the event). */
|
/** When set, Discord notification only shows this match (not all completed matches for the event). */
|
||||||
matchId?: string;
|
matchId?: string;
|
||||||
skipSideEffects?: boolean;
|
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
|
* 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
|
* (e.g. NBA Play-In Round 1 7v8 loser → Play-In Round 2) and must NOT be
|
||||||
|
|
@ -417,7 +570,7 @@ export async function processMatchResult(
|
||||||
providedDb?: ReturnType<typeof database>
|
providedDb?: ReturnType<typeof database>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = providedDb || database();
|
const db = providedDb || database();
|
||||||
const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, loserAdvances } = params;
|
const { round, winnerId, loserId, isScoring, sportsSeasonId, bracketTemplateId, eventId, eventName, matchId, skipSideEffects, skipProbabilities, loserAdvances } = params;
|
||||||
|
|
||||||
if (!isScoring) {
|
if (!isScoring) {
|
||||||
// Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts),
|
// Non-scoring (pre-bracket) round: loser permanently eliminated (0 pts),
|
||||||
|
|
@ -429,8 +582,9 @@ export async function processMatchResult(
|
||||||
if (!loserAdvances) {
|
if (!loserAdvances) {
|
||||||
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
|
await upsertParticipantResult(loserId, sportsSeasonId, 0, db);
|
||||||
}
|
}
|
||||||
if (doesNonScoringRoundFeedIntoScoringRound(round, bracketTemplateId)) {
|
const nonScoringFloor = nonScoringWinnerFloorFor(round, bracketTemplateId);
|
||||||
await upsertParticipantResult(winnerId, sportsSeasonId, 5, db, true);
|
if (nonScoringFloor !== null) {
|
||||||
|
await upsertParticipantResult(winnerId, sportsSeasonId, nonScoringFloor, db, true);
|
||||||
}
|
}
|
||||||
// Non-scoring round wins are not surfaced in the Recent Scores feed.
|
// Non-scoring round wins are not surfaced in the Recent Scores feed.
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -496,6 +650,7 @@ export async function processMatchResult(
|
||||||
: undefined;
|
: undefined;
|
||||||
// Update probabilities first so the standings recalc reads fresh EVs and
|
// Update probabilities first so the standings recalc reads fresh EVs and
|
||||||
// projected points reflect the new result.
|
// projected points reflect the new result.
|
||||||
|
if (!skipProbabilities) {
|
||||||
try {
|
try {
|
||||||
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
await updateProbabilitiesAfterResult(sportsSeasonId, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -504,6 +659,7 @@ export async function processMatchResult(
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
|
await recalculateAffectedLeagues(sportsSeasonId, db, sideEffectOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -612,6 +768,7 @@ export function isLoserNotifiable(
|
||||||
return scoreChanged || finalizedLoserIds.has(loserId);
|
return scoreChanged || finalizedLoserIds.has(loserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function getGuaranteedMinimumPosition(
|
export function getGuaranteedMinimumPosition(
|
||||||
round: string,
|
round: string,
|
||||||
bracketTemplateId: string | null | undefined,
|
bracketTemplateId: string | null | undefined,
|
||||||
|
|
@ -623,13 +780,260 @@ export function getGuaranteedMinimumPosition(
|
||||||
return config.winnerFloor; // null for Finals; actual floor for all other rounds
|
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.
|
* Process a qualifying event completion and update QP totals.
|
||||||
* Ties in QP are handled by sharing placements (averaged points).
|
* Ties in QP are handled by sharing placements (averaged points).
|
||||||
*/
|
*/
|
||||||
export async function processQualifyingEvent(
|
export async function processQualifyingEvent(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
providedDb?: ReturnType<typeof database>
|
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>;
|
||||||
|
} = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = providedDb || database();
|
const db = providedDb || database();
|
||||||
|
|
||||||
|
|
@ -652,9 +1056,40 @@ export async function processQualifyingEvent(
|
||||||
// Get all event results for this qualifying event
|
// Get all event results for this qualifying event
|
||||||
const results = await getEventResults(eventId, db);
|
const results = await getEventResults(eventId, db);
|
||||||
|
|
||||||
// Check if this was already processed (for majorsCompleted counter)
|
// Snapshot awarded QP before reprocessing so the Discord notification below can
|
||||||
const wasAlreadyProcessed = hasProcessedQualifyingPlacement(results);
|
// 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,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
// Clear stale QP before reprocessing so removed/null placements do not keep old awards.
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
await db
|
await db
|
||||||
|
|
@ -671,6 +1106,28 @@ export async function processQualifyingEvent(
|
||||||
qpConfig.map((config) => [config.placement, parseFloat(config.points)])
|
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
|
// Group results by placement to handle ties
|
||||||
const placementGroups = new Map<number, typeof results>();
|
const placementGroups = new Map<number, typeof results>();
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
|
|
@ -683,7 +1140,7 @@ export async function processQualifyingEvent(
|
||||||
|
|
||||||
// Process each placement group and update event_results with QP awarded
|
// Process each placement group and update event_results with QP awarded
|
||||||
for (const [placement, group] of placementGroups) {
|
for (const [placement, group] of placementGroups) {
|
||||||
const tieCount = group.length;
|
const tieCount = canonicalTieCountByPlacement?.get(placement) ?? group.length;
|
||||||
|
|
||||||
const qpPerParticipant = calculateSplitQualifyingPoints(
|
const qpPerParticipant = calculateSplitQualifyingPoints(
|
||||||
placement,
|
placement,
|
||||||
|
|
@ -702,6 +1159,7 @@ export async function processQualifyingEvent(
|
||||||
.where(eq(schema.eventResults.id, result.id));
|
.where(eq(schema.eventResults.id, result.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Recalculate totals for all participants from scratch
|
// Recalculate totals for all participants from scratch
|
||||||
// This is the source of truth - sums all their event_results
|
// This is the source of truth - sums all their event_results
|
||||||
|
|
@ -710,21 +1168,46 @@ export async function processQualifyingEvent(
|
||||||
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment majorsCompleted counter (only if this is the first time processing)
|
// NOTE: majorsCompleted is NOT a stored counter. It is derived on read via
|
||||||
if (!wasAlreadyProcessed) {
|
// getMajorsCompleted() (count of completed qualifying events). Incrementing here
|
||||||
const sportsSeason = event.sportsSeason;
|
// per fan-out sync over-counted it past totalMajors ("11 of 4"), so the write was
|
||||||
await db
|
// removed. See app/models/scoring-event.ts:getMajorsCompleted.
|
||||||
.update(schema.sportsSeasons)
|
|
||||||
.set({
|
|
||||||
majorsCompleted: (sportsSeason.majorsCompleted || 0) + 1,
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(schema.sportsSeasons.id, event.sportsSeasonId));
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log(
|
logger.log(
|
||||||
`[ScoringCalculator] Processed qualifying event ${eventId}: awarded QP to ${results.length} participants`
|
`[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -998,11 +1481,7 @@ export async function calculateTeamScore(
|
||||||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
||||||
}
|
}
|
||||||
const event = await db.query.scoringEvents.findFirst({
|
const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
|
||||||
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
|
||||||
columns: { bracketTemplateId: true },
|
|
||||||
});
|
|
||||||
const templateId = event?.bracketTemplateId ?? null;
|
|
||||||
bracketTemplateCache.set(sportsSeasonId, templateId);
|
bracketTemplateCache.set(sportsSeasonId, templateId);
|
||||||
return templateId;
|
return templateId;
|
||||||
}
|
}
|
||||||
|
|
@ -1111,11 +1590,7 @@ export async function calculateTeamProjectedScore(
|
||||||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
||||||
}
|
}
|
||||||
const event = await db.query.scoringEvents.findFirst({
|
const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
|
||||||
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
|
||||||
columns: { bracketTemplateId: true },
|
|
||||||
});
|
|
||||||
const templateId = event?.bracketTemplateId ?? null;
|
|
||||||
bracketTemplateCache.set(sportsSeasonId, templateId);
|
bracketTemplateCache.set(sportsSeasonId, templateId);
|
||||||
return templateId;
|
return templateId;
|
||||||
}
|
}
|
||||||
|
|
@ -1425,7 +1900,7 @@ export async function recalculateStandings(
|
||||||
export async function recalculateAffectedLeagues(
|
export async function recalculateAffectedLeagues(
|
||||||
sportsSeasonId: string,
|
sportsSeasonId: string,
|
||||||
providedDb?: ReturnType<typeof database>,
|
providedDb?: ReturnType<typeof database>,
|
||||||
options?: { eventName?: string; eventId?: string; matchIds?: string[]; skipDiscord?: boolean }
|
options?: { eventName?: string; eventId?: string; matchIds?: string[]; skipDiscord?: boolean; eliminatedParticipantIds?: string[] }
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = providedDb || database();
|
const db = providedDb || database();
|
||||||
|
|
||||||
|
|
@ -1494,6 +1969,18 @@ export async function recalculateAffectedLeagues(
|
||||||
for (const r of loserResults) finalizedLoserIds.add(r.participantId);
|
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
|
// Recalculate each affected season
|
||||||
for (const seasonId of seasonIds) {
|
for (const seasonId of seasonIds) {
|
||||||
// Capture standings before recalculation for delta calculation
|
// Capture standings before recalculation for delta calculation
|
||||||
|
|
@ -1568,20 +2055,33 @@ export async function recalculateAffectedLeagues(
|
||||||
afterStandings.map((s) => [s.teamId, s.team.ownerId])
|
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.
|
// Build scored matches for the notification.
|
||||||
// Winners only appear if their team's score changed (they earned points this round).
|
// A match is worth announcing when:
|
||||||
// Losers appear if their team's score changed OR they were definitively eliminated
|
// • the winner is owned by a manager AND earned points this round, OR
|
||||||
// (finalPosition set, non-partial) — the latter catches 0-pt eliminations that
|
// • the loser is owned by a manager (eliminated or scored).
|
||||||
// produce no score delta. Losers who advance to another match (loserAdvances=true,
|
// A winning team that merely advanced through a non-scoring round (e.g. R32 → R16
|
||||||
// e.g. NBA 7v8 → PIR2) have no finalized result and no score change, so they're
|
// in the World Cup) does not qualify on its own — their owner hasn't earned anything yet.
|
||||||
// correctly suppressed.
|
// 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.
|
||||||
let scoredMatches: ScoredMatch[] | undefined;
|
let scoredMatches: ScoredMatch[] | undefined;
|
||||||
if (allCompletedMatches.length > 0) {
|
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(
|
const relevant = allCompletedMatches.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
(m.winnerId && draftedIds.has(m.winnerId)) ||
|
(m.winnerId && draftedIds.has(m.winnerId)) ||
|
||||||
|
|
@ -1589,10 +2089,6 @@ export async function recalculateAffectedLeagues(
|
||||||
);
|
);
|
||||||
|
|
||||||
if (relevant.length > 0) {
|
if (relevant.length > 0) {
|
||||||
const teamIdByParticipantId = new Map(
|
|
||||||
draftPicks.map((p) => [p.participantId, p.teamId])
|
|
||||||
);
|
|
||||||
|
|
||||||
const lookupOwner = (participantId: string | null | undefined) => {
|
const lookupOwner = (participantId: string | null | undefined) => {
|
||||||
if (!participantId) return undefined;
|
if (!participantId) return undefined;
|
||||||
const teamId = teamIdByParticipantId.get(participantId);
|
const teamId = teamIdByParticipantId.get(participantId);
|
||||||
|
|
@ -1609,23 +2105,52 @@ export async function recalculateAffectedLeagues(
|
||||||
const winnerOwnerId = lookupOwner(m.winnerId);
|
const winnerOwnerId = lookupOwner(m.winnerId);
|
||||||
const loserOwnerId = lookupOwner(m.loserId);
|
const loserOwnerId = lookupOwner(m.loserId);
|
||||||
const showLoser = isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds);
|
const showLoser = isLoserNotifiable(m.loserId, loserTeamId, changedTeamIds, finalizedLoserIds);
|
||||||
return {
|
return { m, winnerScoreChanged, showLoser, winnerOwnerId, loserOwnerId };
|
||||||
winnerName: m.winnerName ?? "",
|
})
|
||||||
loserName: m.loserName ?? "",
|
.filter((x) => (x.winnerScoreChanged && !!x.winnerOwnerId) || (x.showLoser && !!x.loserOwnerId))
|
||||||
winnerUsername: winnerScoreChanged && winnerOwnerId ? usernameByUserId.get(winnerOwnerId) : undefined,
|
.map((x) => ({
|
||||||
loserUsername: showLoser && loserOwnerId ? usernameByUserId.get(loserOwnerId) : undefined,
|
winnerName: x.m.winnerName ?? "",
|
||||||
winnerDiscordUserId: winnerScoreChanged && winnerOwnerId ? discordIdByUserId.get(winnerOwnerId) : undefined,
|
loserName: x.m.loserName ?? "",
|
||||||
loserDiscordUserId: showLoser && loserOwnerId ? discordIdByUserId.get(loserOwnerId) : undefined,
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip notification if scores didn't change AND no match has a displayable username.
|
// 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.
|
||||||
const hasScoredMatchesToShow = scoredMatches?.some(
|
const hasScoredMatchesToShow = scoredMatches?.some(
|
||||||
(m) => m.winnerUsername !== undefined || m.loserUsername !== undefined
|
(m) => m.winnerUsername !== undefined || m.loserUsername !== undefined
|
||||||
);
|
);
|
||||||
if (!hasChanges && !hasScoredMatchesToShow) continue;
|
const hasEliminatedToShow = (eliminatedTeams?.length ?? 0) > 0;
|
||||||
|
if (!hasChanges && !hasScoredMatchesToShow && !hasEliminatedToShow) continue;
|
||||||
|
|
||||||
const standings = afterStandings.map((s) => ({
|
const standings = afterStandings.map((s) => ({
|
||||||
teamId: s.teamId,
|
teamId: s.teamId,
|
||||||
|
|
@ -1646,6 +2171,7 @@ export async function recalculateAffectedLeagues(
|
||||||
sportName,
|
sportName,
|
||||||
eventName: options?.eventName,
|
eventName: options?.eventName,
|
||||||
scoredMatches,
|
scoredMatches,
|
||||||
|
eliminatedTeams,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Log but don't fail the scoring pipeline if Discord is unreachable
|
// Log but don't fail the scoring pipeline if Discord is unreachable
|
||||||
|
|
|
||||||
11
app/models/scoring-event-types.ts
Normal file
11
app/models/scoring-event-types.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
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,20 +3,11 @@ import * as schema from "~/database/schema";
|
||||||
import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull, sql } from "drizzle-orm";
|
import { eq, and, desc, asc, gte, lte, or, inArray, isNotNull, sql } from "drizzle-orm";
|
||||||
import type { BracketRegion } from "~/lib/bracket-templates";
|
import type { BracketRegion } from "~/lib/bracket-templates";
|
||||||
import { recalculateAffectedLeagues } from "./scoring-calculator";
|
import { recalculateAffectedLeagues } from "./scoring-calculator";
|
||||||
import { hasProcessedQualifyingPlacement, recalculateParticipantQP } from "./qualifying-points";
|
import { recalculateParticipantQP } from "./qualifying-points";
|
||||||
import { findParticipantNamesByIds } from "./season-participant";
|
import { findParticipantNamesByIds } from "./season-participant";
|
||||||
|
import { deleteTournament } from "./tournament";
|
||||||
export type EventType = "playoff_game" | "major_tournament" | "final_standings" | "schedule_event";
|
import type { EventType } from "./scoring-event-types";
|
||||||
|
export { type EventType, getEventTypeLabel } from "./scoring-event-types";
|
||||||
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 {
|
export interface CreateScoringEventData {
|
||||||
sportsSeasonId: string;
|
sportsSeasonId: string;
|
||||||
|
|
@ -42,6 +33,8 @@ export interface UpdateScoringEventData {
|
||||||
scoringStartsAtRound?: string;
|
scoringStartsAtRound?: string;
|
||||||
/** Per-event region config for NCAA-style brackets (overrides template defaults) */
|
/** Per-event region config for NCAA-style brackets (overrides template defaults) */
|
||||||
bracketRegionConfig?: BracketRegion[] | null;
|
bracketRegionConfig?: BracketRegion[] | null;
|
||||||
|
/** External data-source locator (e.g. Wikipedia article title for tennis draws) */
|
||||||
|
externalSourceKey?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -151,6 +144,33 @@ 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
|
* Update a scoring event
|
||||||
*/
|
*/
|
||||||
|
|
@ -173,6 +193,7 @@ export async function updateScoringEvent(
|
||||||
if (data.bracketTemplateId !== undefined) updateData.bracketTemplateId = data.bracketTemplateId;
|
if (data.bracketTemplateId !== undefined) updateData.bracketTemplateId = data.bracketTemplateId;
|
||||||
if (data.scoringStartsAtRound !== undefined) updateData.scoringStartsAtRound = data.scoringStartsAtRound;
|
if (data.scoringStartsAtRound !== undefined) updateData.scoringStartsAtRound = data.scoringStartsAtRound;
|
||||||
if (data.bracketRegionConfig !== undefined) updateData.bracketRegionConfig = data.bracketRegionConfig;
|
if (data.bracketRegionConfig !== undefined) updateData.bracketRegionConfig = data.bracketRegionConfig;
|
||||||
|
if (data.externalSourceKey !== undefined) updateData.externalSourceKey = data.externalSourceKey;
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(schema.scoringEvents)
|
.update(schema.scoringEvents)
|
||||||
|
|
@ -208,28 +229,55 @@ export async function completeScoringEvent(
|
||||||
* sports season (placements are stored there with no FK back to the event) and
|
* sports season (placements are stored there with no FK back to the event) and
|
||||||
* recalculates league standings.
|
* 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(
|
export async function deleteScoringEvent(
|
||||||
eventId: string,
|
eventId: string,
|
||||||
providedDb?: ReturnType<typeof database>
|
providedDb?: ReturnType<typeof database>,
|
||||||
) {
|
opts?: DeleteScoringEventOptions
|
||||||
|
): Promise<DeleteScoringEventResult> {
|
||||||
const db = providedDb || database();
|
const db = providedDb || database();
|
||||||
|
|
||||||
const event = await db.query.scoringEvents.findFirst({
|
const event = await db.query.scoringEvents.findFirst({
|
||||||
where: eq(schema.scoringEvents.id, eventId),
|
where: eq(schema.scoringEvents.id, eventId),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!event) return;
|
if (!event) {
|
||||||
|
return {
|
||||||
|
tournamentId: null,
|
||||||
|
remainingWindows: 0,
|
||||||
|
promotedPrimaryId: null,
|
||||||
|
deletedTournament: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// For qualifying events: capture affected participant IDs before the cascade deletes
|
// For qualifying events: capture affected participant IDs before the cascade deletes
|
||||||
// eventResults (which is how we know who had QP awarded from this event).
|
// eventResults (which is how we know who had QP awarded from this event).
|
||||||
let affectedParticipantIds: string[] = [];
|
let affectedParticipantIds: string[] = [];
|
||||||
let wasQPProcessed = false;
|
|
||||||
if (event.isQualifyingEvent) {
|
if (event.isQualifyingEvent) {
|
||||||
const results = await db.query.eventResults.findMany({
|
const results = await db.query.eventResults.findMany({
|
||||||
where: eq(schema.eventResults.scoringEventId, eventId),
|
where: eq(schema.eventResults.scoringEventId, eventId),
|
||||||
});
|
});
|
||||||
affectedParticipantIds = results.map((r) => r.seasonParticipantId);
|
affectedParticipantIds = results.map((r) => r.seasonParticipantId);
|
||||||
wasQPProcessed = hasProcessedQualifyingPlacement(results);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
|
|
@ -252,21 +300,49 @@ export async function deleteScoringEvent(
|
||||||
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
await recalculateParticipantQP(participantId, event.sportsSeasonId, db);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrement majorsCompleted if this event had already been processed
|
// majorsCompleted is derived on read (getMajorsCompleted), so deleting the event
|
||||||
if (wasQPProcessed) {
|
// — which removes it from the completed-qualifying-event count — self-corrects the
|
||||||
const sportsSeason = await db.query.sportsSeasons.findFirst({
|
// number. No stored counter to decrement.
|
||||||
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);
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -310,10 +386,13 @@ export async function getUpcomingScoringEvents(
|
||||||
|
|
||||||
if (events.length === 0) return [];
|
if (events.length === 0) return [];
|
||||||
|
|
||||||
// Find earliest upcoming game time per event from bracket match games.
|
// Find earliest upcoming game time per event from both bracket match games
|
||||||
|
// and group stage matches (FIFA World Cup style).
|
||||||
const eventIds = events.map((e) => e.id);
|
const eventIds = events.map((e) => e.id);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const gameTimeRows = await db
|
|
||||||
|
const [gameTimeRows, groupStageTimeRows] = await Promise.all([
|
||||||
|
db
|
||||||
.select({
|
.select({
|
||||||
eventId: schema.scoringEvents.id,
|
eventId: schema.scoringEvents.id,
|
||||||
scheduledAt: schema.playoffMatchGames.scheduledAt,
|
scheduledAt: schema.playoffMatchGames.scheduledAt,
|
||||||
|
|
@ -333,10 +412,28 @@ export async function getUpcomingScoringEvents(
|
||||||
isNotNull(schema.playoffMatchGames.scheduledAt),
|
isNotNull(schema.playoffMatchGames.scheduledAt),
|
||||||
gte(schema.playoffMatchGames.scheduledAt, now)
|
gte(schema.playoffMatchGames.scheduledAt, now)
|
||||||
)
|
)
|
||||||
);
|
),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
eventId: schema.tournamentGroups.scoringEventId,
|
||||||
|
scheduledAt: schema.groupStageMatches.scheduledAt,
|
||||||
|
})
|
||||||
|
.from(schema.groupStageMatches)
|
||||||
|
.innerJoin(
|
||||||
|
schema.tournamentGroups,
|
||||||
|
eq(schema.groupStageMatches.tournamentGroupId, schema.tournamentGroups.id)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(schema.tournamentGroups.scoringEventId, eventIds),
|
||||||
|
isNotNull(schema.groupStageMatches.scheduledAt),
|
||||||
|
gte(schema.groupStageMatches.scheduledAt, now)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
const earliestGameById = new Map<string, Date>();
|
const earliestGameById = new Map<string, Date>();
|
||||||
for (const row of gameTimeRows) {
|
for (const row of [...gameTimeRows, ...groupStageTimeRows]) {
|
||||||
if (!row.scheduledAt) continue;
|
if (!row.scheduledAt) continue;
|
||||||
const prev = earliestGameById.get(row.eventId);
|
const prev = earliestGameById.get(row.eventId);
|
||||||
if (!prev || row.scheduledAt < prev) {
|
if (!prev || row.scheduledAt < prev) {
|
||||||
|
|
@ -353,7 +450,15 @@ export async function getUpcomingScoringEvents(
|
||||||
return candidates.length > 0 ? Math.min(...candidates) : Infinity;
|
return candidates.length > 0 ? Math.min(...candidates) : Infinity;
|
||||||
};
|
};
|
||||||
|
|
||||||
return events.toSorted((a, b) => getEffectiveMs(a) - getEffectiveMs(b)).slice(0, limit);
|
return events
|
||||||
|
.toSorted((a, b) => getEffectiveMs(a) - getEffectiveMs(b))
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((e) => ({
|
||||||
|
...e,
|
||||||
|
earliestGameTime: earliestGameById.get(e.id)?.toISOString()
|
||||||
|
?? e.eventStartsAt?.toISOString()
|
||||||
|
?? null,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1038,3 +1143,112 @@ export async function getSportsSeasonsByTournament(tournamentId: string) {
|
||||||
return true;
|
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,14 +134,21 @@ export function calculateSharedPlacementPoints(
|
||||||
* AFL is different: it has TWO distinct tiers in the 5–8 zone:
|
* 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])
|
* - T5-T6: Semi-Finals losers (positions 5 and 6) → avg([5,6])
|
||||||
* - T7-T8: Elimination Finals losers (positions 7 and 8) → avg([7,8])
|
* - 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"]);
|
const SPLIT_5678_TEMPLATE_IDS = new Set(["afl_10", "llws_20"]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Brackets with a real 3rd place game, meaning positions 3 and 4 are distinct
|
* 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.
|
* (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"]);
|
const DISTINCT_34_TEMPLATE_IDS = new Set(["fifa_48", "llws_20"]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate fantasy points for a bracket placement, averaging tied positions.
|
* Calculate fantasy points for a bracket placement, averaging tied positions.
|
||||||
|
|
|
||||||
99
app/models/season-races.ts
Normal file
99
app/models/season-races.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
/**
|
||||||
|
* 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, sql } from "drizzle-orm";
|
import { and, eq, inArray, isNotNull, sql } from "drizzle-orm";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import {
|
import {
|
||||||
|
|
@ -8,12 +8,17 @@ import {
|
||||||
type SimulatorManifestProfile,
|
type SimulatorManifestProfile,
|
||||||
} from "~/services/simulations/manifest";
|
} from "~/services/simulations/manifest";
|
||||||
import {
|
import {
|
||||||
|
getSimulatorInputPolicy,
|
||||||
ratingRequirementLabel,
|
ratingRequirementLabel,
|
||||||
resolveRatings,
|
resolveRatings,
|
||||||
resolveSourceElos,
|
resolveSourceElos,
|
||||||
sourceEloRequirementLabel,
|
sourceEloRequirementLabel,
|
||||||
} from "~/services/simulations/input-policy";
|
} from "~/services/simulations/input-policy";
|
||||||
import { SIMULATOR_TYPES, type SimulatorType } from "~/services/simulations/registry";
|
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 {
|
export interface SimulatorProfile extends SimulatorManifestProfile {
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
|
@ -82,6 +87,12 @@ export interface SportsSeasonSimulatorSummary {
|
||||||
participantInputCount: number;
|
participantInputCount: number;
|
||||||
lastSimulatedDate: string | null;
|
lastSimulatedDate: string | null;
|
||||||
readiness: SimulatorReadiness;
|
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 {
|
function parseDecimal(value: string | null | undefined): number | null {
|
||||||
|
|
@ -125,8 +136,8 @@ function isFallbackMethod(method: string): boolean {
|
||||||
return method === "fallbackElo" || method === "fallbackRating" || method === "averageKnown" || method === "worstKnownMinus";
|
return method === "fallbackElo" || method === "fallbackRating" || method === "averageKnown" || method === "worstKnownMinus";
|
||||||
}
|
}
|
||||||
|
|
||||||
const GENERATED_RATING_METHODS = ["sourceOdds", "fallbackRating", "averageKnown", "worstKnownMinus"] as const;
|
const GENERATED_RATING_METHODS = ["sourceOdds", "blend", "fallbackRating", "averageKnown", "worstKnownMinus"] as const;
|
||||||
const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "fallbackElo", "averageKnown", "worstKnownMinus"] as const;
|
const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "blend", "fallbackElo", "averageKnown", "worstKnownMinus"] as const;
|
||||||
|
|
||||||
function isGeneratedRatingMethod(method: unknown): boolean {
|
function isGeneratedRatingMethod(method: unknown): boolean {
|
||||||
return typeof method === "string" && GENERATED_RATING_METHODS.includes(method as (typeof GENERATED_RATING_METHODS)[number]);
|
return typeof method === "string" && GENERATED_RATING_METHODS.includes(method as (typeof GENERATED_RATING_METHODS)[number]);
|
||||||
|
|
@ -300,112 +311,6 @@ 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(
|
export async function batchUpsertParticipantSimulatorInputs(
|
||||||
inputs: UpsertParticipantSimulatorInput[]
|
inputs: UpsertParticipantSimulatorInput[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|
@ -438,16 +343,44 @@ export async function batchUpsertParticipantSimulatorInputs(
|
||||||
schema.seasonParticipantSimulatorInputs.participantId,
|
schema.seasonParticipantSimulatorInputs.participantId,
|
||||||
schema.seasonParticipantSimulatorInputs.sportsSeasonId,
|
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: {
|
set: {
|
||||||
sourceOdds: sql`excluded.source_odds`,
|
sourceOdds: sql`COALESCE(excluded.source_odds, ${schema.seasonParticipantSimulatorInputs.sourceOdds})`,
|
||||||
sourceElo: sql`excluded.source_elo`,
|
sourceElo: sql`COALESCE(excluded.source_elo, ${schema.seasonParticipantSimulatorInputs.sourceElo})`,
|
||||||
worldRanking: sql`excluded.world_ranking`,
|
worldRanking: sql`COALESCE(excluded.world_ranking, ${schema.seasonParticipantSimulatorInputs.worldRanking})`,
|
||||||
rating: sql`excluded.rating`,
|
rating: sql`COALESCE(excluded.rating, ${schema.seasonParticipantSimulatorInputs.rating})`,
|
||||||
projectedWins: sql`excluded.projected_wins`,
|
projectedWins: sql`COALESCE(excluded.projected_wins, ${schema.seasonParticipantSimulatorInputs.projectedWins})`,
|
||||||
projectedTablePoints: sql`excluded.projected_table_points`,
|
projectedTablePoints: sql`COALESCE(excluded.projected_table_points, ${schema.seasonParticipantSimulatorInputs.projectedTablePoints})`,
|
||||||
seed: sql`excluded.seed`,
|
seed: sql`COALESCE(excluded.seed, ${schema.seasonParticipantSimulatorInputs.seed})`,
|
||||||
region: sql`excluded.region`,
|
region: sql`COALESCE(excluded.region, ${schema.seasonParticipantSimulatorInputs.region})`,
|
||||||
metadata: sql`excluded.metadata`,
|
// 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)`,
|
||||||
updatedAt: sql`excluded.updated_at`,
|
updatedAt: sql`excluded.updated_at`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -569,6 +502,19 @@ 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")) {
|
if (config.profile.setupSections.includes("regularStandings")) {
|
||||||
warnings.push("Regular-season standings may be needed for in-season accuracy.");
|
warnings.push("Regular-season standings may be needed for in-season accuracy.");
|
||||||
}
|
}
|
||||||
|
|
@ -663,6 +609,23 @@ export async function listSportsSeasonSimulatorSummaries(): Promise<SportsSeason
|
||||||
: [];
|
: [];
|
||||||
const lastSnapshotBySeason = new Map(snapshotRows.map((row) => [row.sportsSeasonId, row.lastSimulatedDate]));
|
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).
|
// Each season calls getSimulatorProfile + validateSimulatorReadiness (~4 queries each).
|
||||||
// Acceptable for an admin-only page; revisit if season counts grow past ~50.
|
// Acceptable for an admin-only page; revisit if season counts grow past ~50.
|
||||||
const summaries = await Promise.all(
|
const summaries = await Promise.all(
|
||||||
|
|
@ -670,6 +633,8 @@ export async function listSportsSeasonSimulatorSummaries(): Promise<SportsSeason
|
||||||
const simulatorType = (season.simulatorConfig?.simulatorType ?? season.sport.simulatorType) as SimulatorType;
|
const simulatorType = (season.simulatorConfig?.simulatorType ?? season.sport.simulatorType) as SimulatorType;
|
||||||
const profile = await getSimulatorProfile(simulatorType);
|
const profile = await getSimulatorProfile(simulatorType);
|
||||||
const readiness = await validateSimulatorReadiness(season.id);
|
const readiness = await validateSimulatorReadiness(season.id);
|
||||||
|
const mergedConfig = { ...profile.defaultConfig, ...season.simulatorConfig?.config };
|
||||||
|
const policy = getSimulatorInputPolicy(mergedConfig);
|
||||||
return {
|
return {
|
||||||
sportsSeasonId: season.id,
|
sportsSeasonId: season.id,
|
||||||
seasonName: season.name,
|
seasonName: season.name,
|
||||||
|
|
@ -687,6 +652,9 @@ export async function listSportsSeasonSimulatorSummaries(): Promise<SportsSeason
|
||||||
participantInputCount: readiness.participantInputCount,
|
participantInputCount: readiness.participantInputCount,
|
||||||
lastSimulatedDate: lastSnapshotBySeason.get(season.id) ?? null,
|
lastSimulatedDate: lastSnapshotBySeason.get(season.id) ?? null,
|
||||||
readiness,
|
readiness,
|
||||||
|
supportsFuturesOdds: profile.setupSections.includes("futuresOdds"),
|
||||||
|
oddsParticipantCount: oddsCountBySeason.get(season.id) ?? 0,
|
||||||
|
oddsWeight: policy.oddsWeight,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,57 @@ 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(
|
export async function updateSportsSeason(
|
||||||
id: string,
|
id: string,
|
||||||
data: Partial<NewSportsSeason>
|
data: Partial<NewSportsSeason>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type { TeamStanding, TeamStandingSnapshot, TeamStandingWithChange } from
|
||||||
import { calculateBracketPoints, calculateFantasyPoints } from "~/models/scoring-rules";
|
import { calculateBracketPoints, calculateFantasyPoints } from "~/models/scoring-rules";
|
||||||
import { logger } from "~/lib/logger";
|
import { logger } from "~/lib/logger";
|
||||||
import { getParticipantEV } from "./participant-expected-value";
|
import { getParticipantEV } from "./participant-expected-value";
|
||||||
|
import { getBracketTemplateIdForSportsSeason } from "./bracket-template";
|
||||||
import { calculateEV } from "~/services/ev-calculator";
|
import { calculateEV } from "~/services/ev-calculator";
|
||||||
|
|
||||||
// Re-export types from shared types file
|
// Re-export types from shared types file
|
||||||
|
|
@ -163,11 +164,7 @@ export async function getTeamScoreBreakdown(
|
||||||
if (bracketTemplateCache.has(sportsSeasonId)) {
|
if (bracketTemplateCache.has(sportsSeasonId)) {
|
||||||
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
return bracketTemplateCache.get(sportsSeasonId) ?? null;
|
||||||
}
|
}
|
||||||
const event = await db.query.scoringEvents.findFirst({
|
const templateId = await getBracketTemplateIdForSportsSeason(sportsSeasonId, db);
|
||||||
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
|
||||||
columns: { bracketTemplateId: true },
|
|
||||||
});
|
|
||||||
const templateId = event?.bracketTemplateId ?? null;
|
|
||||||
bracketTemplateCache.set(sportsSeasonId, templateId);
|
bracketTemplateCache.set(sportsSeasonId, templateId);
|
||||||
return templateId;
|
return templateId;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -136,3 +136,17 @@ export async function updateTournamentStatus(
|
||||||
.returning();
|
.returning();
|
||||||
return tournament;
|
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("forgot-password", "routes/forgot-password.tsx"),
|
||||||
route("reset-password", "routes/reset-password.tsx"),
|
route("reset-password", "routes/reset-password.tsx"),
|
||||||
route("onboarding", "routes/onboarding.tsx"),
|
route("onboarding", "routes/onboarding.tsx"),
|
||||||
route("settings", "routes/settings.tsx"),
|
route("settings/:section?", "routes/settings.tsx"),
|
||||||
route("user-profile", "routes/user-profile-redirect.tsx"),
|
route("user-profile", "routes/user-profile-redirect.tsx"),
|
||||||
route("how-to-play", "routes/how-to-play.tsx"),
|
route("how-to-play", "routes/how-to-play.tsx"),
|
||||||
route("rules", "routes/rules.tsx"),
|
route("rules", "routes/rules.tsx"),
|
||||||
|
|
@ -96,6 +96,7 @@ export default [
|
||||||
route("simulators", "routes/admin.simulators.tsx"),
|
route("simulators", "routes/admin.simulators.tsx"),
|
||||||
route("sports/new", "routes/admin.sports.new.tsx"),
|
route("sports/new", "routes/admin.sports.new.tsx"),
|
||||||
route("sports/:id", "routes/admin.sports.$id.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", "routes/admin.sports-seasons.tsx"),
|
||||||
route("sports-seasons/new", "routes/admin.sports-seasons.new.tsx"),
|
route("sports-seasons/new", "routes/admin.sports-seasons.new.tsx"),
|
||||||
route("sports-seasons/:id", "routes/admin.sports-seasons.$id.tsx"),
|
route("sports-seasons/:id", "routes/admin.sports-seasons.$id.tsx"),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
130
app/routes/__tests__/admin.sports-seasons.bracket.clear.test.ts
Normal file
130
app/routes/__tests__/admin.sports-seasons.bracket.clear.test.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
/**
|
||||||
|
* 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
/**
|
||||||
|
* 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
102
app/routes/__tests__/settings.test.ts
Normal file
102
app/routes/__tests__/settings.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
123
app/routes/admin.draft-schedule.tsx
Normal file
123
app/routes/admin.draft-schedule.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
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,6 +77,12 @@ 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) {
|
function statusBadge(status: string) {
|
||||||
if (status === "ready") return <Badge className="gap-1"><CheckCircle2 className="h-3 w-3" /> Ready</Badge>;
|
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>;
|
return <Badge variant="secondary" className="gap-1"><XCircle className="h-3 w-3" /> Needs setup</Badge>;
|
||||||
|
|
@ -245,7 +251,14 @@ export default function AdminSimulators({ loaderData }: Route.ComponentProps) {
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
{sim.participantInputCount}/{sim.participantCount}
|
<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>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
{sim.lastSimulatedDate ?? "Never"}
|
{sim.lastSimulatedDate ?? "Never"}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ import {
|
||||||
projectedWinsToElo,
|
projectedWinsToElo,
|
||||||
} from '~/services/probability-engine';
|
} from '~/services/probability-engine';
|
||||||
import { runSportsSeasonSimulation } from '~/services/simulations/runner';
|
import { runSportsSeasonSimulation } from '~/services/simulations/runner';
|
||||||
import { getSportsSeasonSimulatorConfig } from '~/models/simulator';
|
import { getParticipantSimulatorInputs, getSportsSeasonSimulatorConfig } from '~/models/simulator';
|
||||||
|
|
||||||
// Simulator types that use worldRanking in addition to sourceElo
|
// Simulator types that use worldRanking in addition to sourceElo
|
||||||
const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points', 'college_hockey_bracket']);
|
const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points', 'college_hockey_bracket']);
|
||||||
|
|
@ -80,14 +80,39 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
|
|
||||||
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
|
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
|
||||||
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
||||||
|
const simulatorInputs = await getParticipantSimulatorInputs(sportsSeasonId);
|
||||||
|
|
||||||
const existingData: Record<string, { elo: number | null; ranking: number | null }> = {};
|
// The projection a participant was actually saved with. Read it back verbatim:
|
||||||
for (const ev of existingEVs) {
|
// deriving the field from the stored Elo instead (as this page used to) shows the
|
||||||
existingData[ev.participantId] = {
|
// admin a different number than they typed, because wins → Elo rounds to an
|
||||||
elo: ev.sourceElo ?? null,
|
// integer Elo and a simulation run then re-resolves that Elo through the input
|
||||||
ranking: ev.worldRanking ?? null,
|
// 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
for (const ev of existingEVs) {
|
||||||
|
const existing = existingData[ev.participantId];
|
||||||
|
if (!existing) continue;
|
||||||
|
existing.elo = ev.sourceElo ?? null;
|
||||||
|
existing.ranking = ev.worldRanking ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? '');
|
const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? '');
|
||||||
|
|
||||||
|
|
@ -252,7 +277,16 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
if (simulatorConfig) {
|
if (simulatorConfig) {
|
||||||
participants.forEach(p => {
|
participants.forEach(p => {
|
||||||
const d = existingData[p.id];
|
const d = existingData[p.id];
|
||||||
if (d?.elo !== null && d?.elo !== undefined) {
|
// 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) {
|
||||||
initial[p.id] = (simulatorConfig.projectionInput === 'tablePoints'
|
initial[p.id] = (simulatorConfig.projectionInput === 'tablePoints'
|
||||||
? eloToProjectedTablePoints(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
? eloToProjectedTablePoints(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||||||
: eloToProjectedWins(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
: eloToProjectedWins(d.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
||||||
|
|
@ -265,8 +299,8 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
|
|
||||||
const [bulkText, setBulkText] = useState('');
|
const [bulkText, setBulkText] = useState('');
|
||||||
const [parseResults, setParseResults] = useState<{
|
const [parseResults, setParseResults] = useState<{
|
||||||
matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }>;
|
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 }>;
|
unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null; projection: number | null }>;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
function findParticipantMatch(inputName: string) {
|
function findParticipantMatch(inputName: string) {
|
||||||
|
|
@ -291,8 +325,8 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
|
|
||||||
function parseBulkText() {
|
function parseBulkText() {
|
||||||
const lines = bulkText.split('\n');
|
const lines = bulkText.split('\n');
|
||||||
const matched: Array<{ participantId: string; name: string; elo: number | null; ranking: number | null; inputName: string }> = [];
|
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 }> = [];
|
const unmatched: Array<{ inputName: string; elo: number | null; ranking: number | null; projection: number | null }> = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
|
@ -315,9 +349,9 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
const participant = findParticipantMatch(inputName);
|
const participant = findParticipantMatch(inputName);
|
||||||
if (participant && !seen.has(participant.id)) {
|
if (participant && !seen.has(participant.id)) {
|
||||||
seen.add(participant.id);
|
seen.add(participant.id);
|
||||||
matched.push({ participantId: participant.id, name: participant.name, elo, ranking: null, inputName });
|
matched.push({ participantId: participant.id, name: participant.name, elo, ranking: null, projection: projectedWins, inputName });
|
||||||
} else if (!participant) {
|
} else if (!participant) {
|
||||||
unmatched.push({ inputName, elo, ranking: null });
|
unmatched.push({ inputName, elo, ranking: null, projection: projectedWins });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const match = usesRanking
|
const match = usesRanking
|
||||||
|
|
@ -342,9 +376,9 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
const participant = findParticipantMatch(inputName);
|
const participant = findParticipantMatch(inputName);
|
||||||
if (participant && !seen.has(participant.id)) {
|
if (participant && !seen.has(participant.id)) {
|
||||||
seen.add(participant.id);
|
seen.add(participant.id);
|
||||||
matched.push({ participantId: participant.id, name: participant.name, elo, ranking, inputName });
|
matched.push({ participantId: participant.id, name: participant.name, elo, ranking, projection: null, inputName });
|
||||||
} else if (!participant) {
|
} else if (!participant) {
|
||||||
unmatched.push({ inputName, elo, ranking });
|
unmatched.push({ inputName, elo, ranking, projection: null });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -360,11 +394,11 @@ export default function AdminSportsSeasonEloRatings() {
|
||||||
for (const m of parseResults.matched) {
|
for (const m of parseResults.matched) {
|
||||||
if (m.elo !== null) newElos[m.participantId] = m.elo.toString();
|
if (m.elo !== null) newElos[m.participantId] = m.elo.toString();
|
||||||
if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString();
|
if (m.ranking !== null) newRanks[m.participantId] = m.ranking.toString();
|
||||||
if (inputMode === 'projectedWins' && simulatorConfig && m.elo !== null) {
|
// The pasted number goes in as typed. Round-tripping it through the derived
|
||||||
newWins[m.participantId] = (simulatorConfig.projectionInput === 'tablePoints'
|
// Elo (as this used to) drifts it by up to half an Elo point — a pasted 95
|
||||||
? eloToProjectedTablePoints(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
// came back as 95.1 before anything was even saved.
|
||||||
: eloToProjectedWins(m.elo, simulatorConfig.seasonGames, simulatorConfig.parityFactor, simulatorConfig.averageOpponentElo)
|
if (inputMode === 'projectedWins' && m.projection !== null) {
|
||||||
).toFixed(1);
|
newWins[m.participantId] = m.projection.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setEloValues(newElos);
|
setEloValues(newElos);
|
||||||
|
|
@ -489,7 +523,10 @@ Mark Selby, 2432`
|
||||||
<div key={m.participantId} className="flex justify-between px-3 py-1.5">
|
<div key={m.participantId} className="flex justify-between px-3 py-1.5">
|
||||||
<span className="text-muted-foreground">{m.inputName}</span>
|
<span className="text-muted-foreground">{m.inputName}</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{m.name} → {m.elo !== null ? `Elo ${m.elo}` : 'No Elo'}
|
{m.name} →{' '}
|
||||||
|
{m.projection !== null
|
||||||
|
? `${m.projection} ${projectionUnit} (Elo ${m.elo})`
|
||||||
|
: m.elo !== null ? `Elo ${m.elo}` : 'No Elo'}
|
||||||
{usesRanking && m.ranking !== null ? `, ${rankLabel} #${m.ranking}` : ''}
|
{usesRanking && m.ranking !== null ? `, ${rankLabel} #${m.ranking}` : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -509,7 +546,9 @@ Mark Selby, 2432`
|
||||||
<div key={u.inputName} className="flex justify-between px-3 py-1.5">
|
<div key={u.inputName} className="flex justify-between px-3 py-1.5">
|
||||||
<span>{u.inputName}</span>
|
<span>{u.inputName}</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{u.elo !== null ? `Elo ${u.elo}` : 'No Elo'}
|
{u.projection !== null
|
||||||
|
? `${u.projection} ${projectionUnit} (Elo ${u.elo})`
|
||||||
|
: u.elo !== null ? `Elo ${u.elo}` : 'No Elo'}
|
||||||
{usesRanking && u.ranking !== null ? `, ${rankLabel} #${u.ranking}` : ''}
|
{usesRanking && u.ranking !== null ? `, ${rankLabel} #${u.ranking}` : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -540,7 +579,7 @@ Mark Selby, 2432`
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{inputMode === 'projectedWins'
|
{inputMode === 'projectedWins'
|
||||||
? `Enter each team's projected total season ${projectionUnit}. Converted to Elo automatically. Saving will run the simulation and update expected values.`
|
? `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.`
|
||||||
: usesRanking
|
: 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 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.`}
|
: `Enter each ${participantLabel.toLowerCase()}'s current Elo rating. Saving will automatically run the simulation and update expected values.`}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/**
|
||||||
|
* 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,16 +1,23 @@
|
||||||
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
|
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
|
||||||
|
|
||||||
import { findSportsSeasonById } from "~/models/sports-season";
|
import { findSportsSeasonById } from "~/models/sports-season";
|
||||||
import { findParticipantsBySportsSeasonId } from "~/models/season-participant";
|
import {
|
||||||
import { getScoringEventById, updateScoringEvent } from "~/models/scoring-event";
|
findParticipantsBySportsSeasonId,
|
||||||
|
createParticipant,
|
||||||
|
updateParticipant,
|
||||||
|
} from "~/models/season-participant";
|
||||||
|
import { getScoringEventById, updateScoringEvent, isReadOnlySibling } from "~/models/scoring-event";
|
||||||
import {
|
import {
|
||||||
findPlayoffMatchesByEventId,
|
findPlayoffMatchesByEventId,
|
||||||
|
deletePlayoffMatchesByEventId,
|
||||||
generateBracketFromTemplate,
|
generateBracketFromTemplate,
|
||||||
setMatchWinner,
|
setMatchWinner,
|
||||||
advanceWinnerTemplate,
|
advanceWinnerTemplate,
|
||||||
findPlayoffMatchById,
|
findPlayoffMatchById,
|
||||||
assignParticipantsToKnockout,
|
assignParticipantsToKnockout,
|
||||||
doesLoserAdvance,
|
doesLoserAdvance,
|
||||||
|
reseedAflEliminationFinals,
|
||||||
|
reseedAflSemiFinals,
|
||||||
} from "~/models/playoff-match";
|
} from "~/models/playoff-match";
|
||||||
import {
|
import {
|
||||||
createGame,
|
createGame,
|
||||||
|
|
@ -25,13 +32,22 @@ import {
|
||||||
import {
|
import {
|
||||||
processPlayoffEvent,
|
processPlayoffEvent,
|
||||||
processMatchResult,
|
processMatchResult,
|
||||||
|
processQualifyingBracketEvent,
|
||||||
|
processQualifyingEvent,
|
||||||
|
finalizeQualifyingPoints,
|
||||||
recalculateAffectedLeagues,
|
recalculateAffectedLeagues,
|
||||||
recalculateStandings,
|
recalculateStandings,
|
||||||
autoCompleteRoundIfDone,
|
autoCompleteRoundIfDone,
|
||||||
|
applyBracketEntryFloors,
|
||||||
} from "~/models/scoring-calculator";
|
} from "~/models/scoring-calculator";
|
||||||
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
import { updateProbabilitiesAfterResult } from "~/services/probability-updater";
|
||||||
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
|
import { getBracketTemplate, ALL_16_SEEDS, type BracketRegion } from "~/lib/bracket-templates";
|
||||||
import { setParticipantResult, findParticipantResultsBySportsSeasonId } from "~/models/participant-result";
|
import {
|
||||||
|
setParticipantResult,
|
||||||
|
findParticipantResultsBySportsSeasonId,
|
||||||
|
deleteParticipantResultsBySportsSeasonId,
|
||||||
|
deleteParticipantResultsForParticipants,
|
||||||
|
} from "~/models/participant-result";
|
||||||
import { findSeasonSportsBySportsSeasonId } from "~/models/season-sport";
|
import { findSeasonSportsBySportsSeasonId } from "~/models/season-sport";
|
||||||
import { createDailySnapshot } from "~/models/standings";
|
import { createDailySnapshot } from "~/models/standings";
|
||||||
import {
|
import {
|
||||||
|
|
@ -54,6 +70,10 @@ import { database } from "~/database/context";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { maybeResolveCompletedBracktForSportsSeason } from "~/services/brackt.server";
|
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) {
|
export async function loader({ params }: Route.LoaderArgs) {
|
||||||
const sportsSeason = await findSportsSeasonById(params.id);
|
const sportsSeason = await findSportsSeasonById(params.id);
|
||||||
|
|
@ -103,10 +123,230 @@ 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) {
|
export async function action({ request, params }: Route.ActionArgs) {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const intent = formData.get("intent");
|
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") {
|
if (intent === "generate-bracket") {
|
||||||
const templateId = formData.get("templateId");
|
const templateId = formData.get("templateId");
|
||||||
|
|
||||||
|
|
@ -170,36 +410,50 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
try {
|
try {
|
||||||
await generateBracketFromTemplate(params.eventId, templateId, participantIds, regionOverride);
|
await generateBracketFromTemplate(params.eventId, templateId, participantIds, regionOverride);
|
||||||
|
|
||||||
// PHASE 5.3: Mark participants NOT in the bracket as eliminated
|
// The template ID has to land on the event before entry floors can be derived
|
||||||
const event = await getScoringEventById(params.eventId);
|
// (getBracketEntryFloor reads it), so persist it here rather than after the
|
||||||
if (event) {
|
// elimination pass below.
|
||||||
// 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, {
|
await updateScoringEvent(params.eventId, {
|
||||||
bracketTemplateId: templateId,
|
bracketTemplateId: templateId,
|
||||||
scoringStartsAtRound: template.scoringStartsAtRound,
|
scoringStartsAtRound: template.scoringStartsAtRound,
|
||||||
bracketRegionConfig: regionOverride,
|
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" };
|
return { success: "Bracket generated successfully" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error generating bracket:", error);
|
logger.error("Error generating bracket:", error);
|
||||||
|
|
@ -246,6 +500,13 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
return { error: "Could not determine loser" };
|
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
|
// Set the winner
|
||||||
await setMatchWinner(matchId, winnerId, loserId);
|
await setMatchWinner(matchId, winnerId, loserId);
|
||||||
|
|
||||||
|
|
@ -267,6 +528,22 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
// Immediately score this match: loser gets their final placement,
|
||||||
// winner gets provisional floor points (isPartialScore=true).
|
// winner gets provisional floor points (isPartialScore=true).
|
||||||
// Prefer the template-defined isScoring over the DB field: the DB column
|
// Prefer the template-defined isScoring over the DB field: the DB column
|
||||||
|
|
@ -285,8 +562,8 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
|
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
|
||||||
});
|
});
|
||||||
|
|
||||||
const db = database();
|
|
||||||
await autoCompleteRoundIfDone(event.id, match.round, event.sportsSeasonId, db);
|
await autoCompleteRoundIfDone(event.id, match.round, event.sportsSeasonId, db);
|
||||||
|
}
|
||||||
|
|
||||||
return { success: "Winner set successfully" };
|
return { success: "Winner set successfully" };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -337,6 +614,10 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
let successCount = 0;
|
let successCount = 0;
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
const processedMatchIds: 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) {
|
for (const { matchId, winnerId } of winnerAssignments) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -385,6 +666,9 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
|
|
||||||
// Score this match without triggering standings/probability recalc yet —
|
// Score this match without triggering standings/probability recalc yet —
|
||||||
// we batch those side effects into a single call after the loop.
|
// 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({
|
await processMatchResult({
|
||||||
round: match.round,
|
round: match.round,
|
||||||
winnerId,
|
winnerId,
|
||||||
|
|
@ -398,9 +682,14 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
skipSideEffects: true,
|
skipSideEffects: true,
|
||||||
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
|
loserAdvances: doesLoserAdvance(match.round, match.matchNumber, event.bracketTemplateId ?? ""),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
successCount++;
|
successCount++;
|
||||||
processedMatchIds.push(matchId);
|
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) {
|
} catch (error) {
|
||||||
logger.error(`Error setting winner for match ${matchId}:`, error);
|
logger.error(`Error setting winner for match ${matchId}:`, error);
|
||||||
errors.push(
|
errors.push(
|
||||||
|
|
@ -414,6 +703,15 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
// previously completed matches in the event.
|
// previously completed matches in the event.
|
||||||
if (successCount > 0) {
|
if (successCount > 0) {
|
||||||
const db = database();
|
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
|
// Update probabilities first so recalculateAffectedLeagues reads fresh EVs
|
||||||
// when computing projected points.
|
// when computing projected points.
|
||||||
try {
|
try {
|
||||||
|
|
@ -422,7 +720,18 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
logger.error(`Error updating probabilities after batch round winners:`, error);
|
logger.error(`Error updating probabilities after batch round winners:`, error);
|
||||||
}
|
}
|
||||||
await recalculateAffectedLeagues(event.sportsSeasonId, db, { eventId: event.id, eventName: event.name ?? undefined, matchIds: processedMatchIds });
|
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);
|
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)),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errors.length > 0 && successCount === 0) {
|
if (errors.length > 0 && successCount === 0) {
|
||||||
|
|
@ -474,6 +783,14 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
return { error: `Not all matches in ${round} are complete` };
|
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
|
// Validate round order: ensure previous rounds are complete
|
||||||
const existingResults = await findParticipantResultsBySportsSeasonId(
|
const existingResults = await findParticipantResultsBySportsSeasonId(
|
||||||
params.id
|
params.id
|
||||||
|
|
@ -551,6 +868,101 @@ 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") {
|
if (intent === "reprocess-bracket") {
|
||||||
try {
|
try {
|
||||||
const event = await getScoringEventById(params.eventId);
|
const event = await getScoringEventById(params.eventId);
|
||||||
|
|
@ -559,20 +971,87 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
const matches = await findPlayoffMatchesByEventId(params.eventId);
|
const matches = await findPlayoffMatchesByEventId(params.eventId);
|
||||||
const completed = matches.filter((m) => m.isComplete && m.winnerId && m.loserId);
|
const completed = matches.filter((m) => m.isComplete && m.winnerId && m.loserId);
|
||||||
|
|
||||||
if (completed.length === 0) {
|
// Qualifying majors: this is also the cleanup tool for majors that wrongly banked
|
||||||
return { error: "No completed matches to reprocess" };
|
// 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.` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete ALL results for this sports season and rebuild from scratch.
|
if (matches.length === 0) {
|
||||||
// Only deleting partial rows leaves stale finalized rows that block
|
return { error: "No bracket to reprocess" };
|
||||||
// the "never un-finalize" guard in upsertParticipantResult.
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
const db = database();
|
const db = database();
|
||||||
await db
|
// Reused further down to decide who is *not* in the bracket and so eliminated.
|
||||||
.delete(schema.seasonParticipantResults)
|
const bracketParticipantIds = new Set<string>();
|
||||||
.where(
|
for (const match of matches) {
|
||||||
eq(schema.seasonParticipantResults.sportsSeasonId, event.sportsSeasonId)
|
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);
|
||||||
|
|
||||||
// Replay each completed match in bracket order (earlier rounds first).
|
// Replay each completed match in bracket order (earlier rounds first).
|
||||||
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
|
const template = event.bracketTemplateId ? getBracketTemplate(event.bracketTemplateId) : null;
|
||||||
const roundOrder = template ? template.rounds.map((r) => r.name) : [];
|
const roundOrder = template ? template.rounds.map((r) => r.name) : [];
|
||||||
|
|
@ -612,11 +1091,6 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
// Mark participants NOT in any bracket match as eliminated (finalPosition = 0).
|
// Mark participants NOT in any bracket match as eliminated (finalPosition = 0).
|
||||||
// This covers teams that didn't make the playoffs/play-in tournament.
|
// This covers teams that didn't make the playoffs/play-in tournament.
|
||||||
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
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;
|
let eliminatedCount = 0;
|
||||||
for (const participant of allParticipants) {
|
for (const participant of allParticipants) {
|
||||||
if (!bracketParticipantIds.has(participant.id)) {
|
if (!bracketParticipantIds.has(participant.id)) {
|
||||||
|
|
@ -632,7 +1106,12 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
// skipDiscord: reprocess-bracket is a data-correction tool, not a result announcement.
|
// skipDiscord: reprocess-bracket is a data-correction tool, not a result announcement.
|
||||||
await recalculateAffectedLeagues(event.sportsSeasonId, undefined, { skipDiscord: true });
|
await recalculateAffectedLeagues(event.sportsSeasonId, undefined, { skipDiscord: true });
|
||||||
|
|
||||||
return { success: `Reprocessed bracket: ${sortedMatches.length} match(es) replayed, ${eliminatedCount} non-bracket participant(s) eliminated` };
|
return {
|
||||||
|
success:
|
||||||
|
`Reprocessed bracket: ${sortedMatches.length} match(es) replayed, ` +
|
||||||
|
`${entryFloorCount} seeded participant(s) given their guaranteed entry floor, ` +
|
||||||
|
`${eliminatedCount} non-bracket participant(s) eliminated`,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error reprocessing bracket:", error);
|
logger.error("Error reprocessing bracket:", error);
|
||||||
return {
|
return {
|
||||||
|
|
@ -669,6 +1148,31 @@ 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
|
// Get all participants in this sports season
|
||||||
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
const allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
||||||
|
|
||||||
|
|
@ -783,14 +1287,19 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
await generateBracketFromTemplate(params.eventId, templateId);
|
await generateBracketFromTemplate(params.eventId, templateId);
|
||||||
|
|
||||||
// Eliminate participants from this sport season who are not in any group
|
// 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 allParticipants = await findParticipantsBySportsSeasonId(params.id);
|
||||||
let eliminatedCount = 0;
|
const toEliminate = allParticipants
|
||||||
for (const participant of allParticipants) {
|
.filter((p) => !uniqueParticipants.has(p.id))
|
||||||
if (!uniqueParticipants.has(participant.id)) {
|
.map((p) => p.id);
|
||||||
await setParticipantResult(participant.id, params.id, 0);
|
const { markedCount: eliminatedCount } = await markEliminatedAndAnnounce(
|
||||||
eliminatedCount++;
|
groupsEvent,
|
||||||
}
|
toEliminate
|
||||||
}
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: `Groups and knockout bracket structure created successfully${eliminatedCount > 0 ? ` (${eliminatedCount} participant(s) not in any group marked as eliminated)` : ""}`,
|
success: `Groups and knockout bracket structure created successfully${eliminatedCount > 0 ? ` (${eliminatedCount} participant(s) not in any group marked as eliminated)` : ""}`,
|
||||||
|
|
@ -915,11 +1424,10 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
// Assign participants to knockout bracket
|
// Assign participants to knockout bracket
|
||||||
await assignParticipantsToKnockout(params.eventId, assignments);
|
await assignParticipantsToKnockout(params.eventId, assignments);
|
||||||
|
|
||||||
// Mark eliminated group participants with finalPosition = 0
|
// Mark eliminated group participants with finalPosition = 0 (and announce
|
||||||
|
// the group-stage eliminations to leagues for fantasy events).
|
||||||
const eliminatedIds = await getEliminatedParticipantIds(params.eventId);
|
const eliminatedIds = await getEliminatedParticipantIds(params.eventId);
|
||||||
for (const participantId of eliminatedIds) {
|
await markEliminatedAndAnnounce(event, eliminatedIds);
|
||||||
await setParticipantResult(participantId, event.sportsSeasonId, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log(
|
logger.log(
|
||||||
`[PopulateKnockout] Assigned 32 participants to knockout, marked ${eliminatedIds.length} as eliminated`
|
`[PopulateKnockout] Assigned 32 participants to knockout, marked ${eliminatedIds.length} as eliminated`
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Form, Link } from "react-router";
|
import { Form, Link, useFetcher } from "react-router";
|
||||||
import { Fragment, useState, useEffect, useMemo } from "react";
|
import { Fragment, useState, useEffect, useMemo, useRef } from "react";
|
||||||
import { localDateTimeToUtcIso } from "~/lib/date-utils";
|
import { localDateTimeToUtcIso, utcIsoToLocalDateTime } from "~/lib/date-utils";
|
||||||
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
|
import type { Route } from "./+types/admin.sports-seasons.$id.events.$eventId.bracket";
|
||||||
|
|
||||||
import { loader, action } from "./admin.sports-seasons.$id.events.$eventId.bracket.server";
|
import { loader, action } from "./admin.sports-seasons.$id.events.$eventId.bracket.server";
|
||||||
|
|
@ -39,8 +39,116 @@ export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
return [{ title: `Bracket — ${data?.event?.name ?? "Event"} - Brackt Admin` }];
|
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 };
|
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({
|
export default function EventBracket({
|
||||||
loaderData,
|
loaderData,
|
||||||
actionData,
|
actionData,
|
||||||
|
|
@ -323,6 +431,164 @@ export default function EventBracket({
|
||||||
</div>
|
</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.
|
{/* Reprocess Bracket - Full rebuild of participant results.
|
||||||
Hidden once every match is complete — at that point all placements are final
|
Hidden once every match is complete — at that point all placements are final
|
||||||
and "finalize bracket" should be used instead. */}
|
and "finalize bracket" should be used instead. */}
|
||||||
|
|
@ -347,6 +613,110 @@ export default function EventBracket({
|
||||||
</Card>
|
</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 ====== */}
|
{/* ====== SETUP PHASE ====== */}
|
||||||
{showSetup && (
|
{showSetup && (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -623,7 +993,7 @@ export default function EventBracket({
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div key={i} className="flex items-center gap-2">
|
||||||
<Label className="w-20 text-sm text-muted-foreground shrink-0">
|
<Label className="w-28 text-sm text-muted-foreground shrink-0">
|
||||||
{slotLabel}
|
{slotLabel}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|
@ -806,24 +1176,7 @@ export default function EventBracket({
|
||||||
{groupMatches.map((match) => (
|
{groupMatches.map((match) => (
|
||||||
<div key={match.id} className="text-xs space-y-1">
|
<div key={match.id} className="text-xs space-y-1">
|
||||||
{/* Schedule row */}
|
{/* Schedule row */}
|
||||||
<Form method="post" className="flex items-center gap-1">
|
<GroupMatchScheduleForm match={match} localTzAbbr={localTzAbbr} />
|
||||||
<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 */}
|
{/* Score row */}
|
||||||
<Form method="post" className="flex items-center gap-1">
|
<Form method="post" className="flex items-center gap-1">
|
||||||
<input type="hidden" name="intent" value="update-group-match" />
|
<input type="hidden" name="intent" value="update-group-match" />
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,19 @@ import { Form, useLoaderData, useActionData, useNavigation, useFetcher, Link } f
|
||||||
import type { Route } from './+types/admin.sports-seasons.$id.events.$eventId.cs2-setup';
|
import type { Route } from './+types/admin.sports-seasons.$id.events.$eventId.cs2-setup';
|
||||||
|
|
||||||
import { findSportsSeasonById } from '~/models/sports-season';
|
import { findSportsSeasonById } from '~/models/sports-season';
|
||||||
import { getScoringEventById } from '~/models/scoring-event';
|
import { getScoringEventById, isReadOnlySibling } from '~/models/scoring-event';
|
||||||
import { findParticipantsBySportsSeasonId } from '~/models/season-participant';
|
import { findParticipantsBySportsSeasonId } from '~/models/season-participant';
|
||||||
import {
|
import {
|
||||||
getCs2StageResultsForEvent,
|
getCs2StageResultsForEvent,
|
||||||
upsertCs2StageAssignments,
|
upsertCs2StageAssignments,
|
||||||
markCs2StageEliminations,
|
markCs2StageEliminations,
|
||||||
clearCs2StageAssignments,
|
resetCs2Event,
|
||||||
clearCs2EliminationsAtStage,
|
clearCs2EliminationsAtStage,
|
||||||
assignCs2EliminationQP,
|
assignCs2EliminationQP,
|
||||||
} from '~/models/cs2-major-stage';
|
} from '~/models/cs2-major-stage';
|
||||||
import { findSeasonMatchesByScoringEventId } from '~/models/season-match';
|
import { findSeasonMatchesByScoringEventId } from '~/models/season-match';
|
||||||
import { syncMatches } from '~/services/match-sync';
|
import { syncMatches } from '~/services/match-sync';
|
||||||
|
import { fanOutMajorIfPrimary } from '~/services/sync-tournament-results';
|
||||||
import { Button } from '~/components/ui/button';
|
import { Button } from '~/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -61,9 +62,22 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const intent = formData.get('intent') as string;
|
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') {
|
if (intent === 'reset') {
|
||||||
await clearCs2StageAssignments(eventId);
|
// Local recovery tool: clears only the primary's torn state. Siblings are not
|
||||||
return { success: true, message: 'Stage assignments cleared.' };
|
// 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.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intent === 'assign') {
|
if (intent === 'assign') {
|
||||||
|
|
@ -126,6 +140,10 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
await markCs2StageEliminations(eventId, eliminations);
|
await markCs2StageEliminations(eventId, eliminations);
|
||||||
await assignCs2EliminationQP(eventId, params.id);
|
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.` };
|
return { success: true, message: `Marked ${eliminations.length} eliminations.` };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { success: false, message: err instanceof Error ? err.message : 'Failed to save eliminations.' };
|
return { success: false, message: err instanceof Error ? err.message : 'Failed to save eliminations.' };
|
||||||
|
|
@ -268,7 +286,14 @@ export default function AdminCs2Setup() {
|
||||||
<CardTitle className="flex items-center justify-between">
|
<CardTitle className="flex items-center justify-between">
|
||||||
Stage Assignments
|
Stage Assignments
|
||||||
{stageResults.length > 0 && (
|
{stageResults.length > 0 && (
|
||||||
<Form method="post">
|
<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();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<input type="hidden" name="intent" value="reset" />
|
<input type="hidden" name="intent" value="reset" />
|
||||||
<Button type="submit" variant="ghost" size="sm" className="text-destructive">
|
<Button type="submit" variant="ghost" size="sm" className="text-destructive">
|
||||||
<Trash2 className="h-4 w-4 mr-1" />
|
<Trash2 className="h-4 w-4 mr-1" />
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
getScoringEventById,
|
getScoringEventById,
|
||||||
completeScoringEvent,
|
completeScoringEvent,
|
||||||
updateScoringEvent,
|
updateScoringEvent,
|
||||||
|
isReadOnlySibling,
|
||||||
} from "~/models/scoring-event";
|
} from "~/models/scoring-event";
|
||||||
import {
|
import {
|
||||||
getEventResults,
|
getEventResults,
|
||||||
|
|
@ -77,6 +78,9 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
)
|
)
|
||||||
: new Set();
|
: 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 {
|
return {
|
||||||
sportsSeason: sportsSeason as typeof sportsSeason & {
|
sportsSeason: sportsSeason as typeof sportsSeason & {
|
||||||
sport: { id: string; name: string; type: string; slug: string; simulatorType: string | null };
|
sport: { id: string; name: string; type: string; slug: string; simulatorType: string | null };
|
||||||
|
|
@ -89,13 +93,45 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
qpConfig,
|
qpConfig,
|
||||||
qpStandings,
|
qpStandings,
|
||||||
notParticipatingIds,
|
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) {
|
export async function action({ request, params }: Route.ActionArgs) {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const intent = formData.get("intent");
|
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") {
|
if (intent === "mark-qualifying") {
|
||||||
try {
|
try {
|
||||||
const event = await getScoringEventById(params.eventId);
|
const event = await getScoringEventById(params.eventId);
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue